BigBoa19 1 day ago committed by GitHub
parent
commit
b5c350f98d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 11
      packages/excalidraw/actions/actionAddToLibrary.ts
  2. 49
      packages/excalidraw/components/App.tsx
  3. 20
      packages/excalidraw/components/ContextMenu.tsx
  4. 1
      packages/excalidraw/components/LibraryMenu.scss
  5. 6
      packages/excalidraw/components/LibraryMenu.tsx
  6. 110
      packages/excalidraw/components/LibraryMenuHeaderContent.tsx
  7. 96
      packages/excalidraw/components/LibraryMenuItems.scss
  8. 386
      packages/excalidraw/components/LibraryMenuItems.tsx
  9. 349
      packages/excalidraw/data/library.ts
  10. 395
      packages/excalidraw/tests/libraryCollections.test.tsx
  11. 33
      packages/excalidraw/types.ts

11
packages/excalidraw/actions/actionAddToLibrary.ts

@ -10,7 +10,7 @@ import { register } from "./register"; @@ -10,7 +10,7 @@ import { register } from "./register";
export const actionAddToLibrary = register({
name: "addToLibrary",
trackEvent: { category: "element" },
perform: (elements, appState, _, app) => {
perform: (elements, appState, value, app) => {
const selectedElements = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true,
@ -29,6 +29,14 @@ export const actionAddToLibrary = register({ @@ -29,6 +29,14 @@ export const actionAddToLibrary = register({
}
}
// value can be a collectionId string or an object with collectionId
const collectionId =
typeof value === "string"
? value
: value && typeof value === "object" && "collectionId" in value
? (value as { collectionId: string }).collectionId
: undefined;
return app.library
.getLatestLibrary()
.then((items) => {
@ -38,6 +46,7 @@ export const actionAddToLibrary = register({ @@ -38,6 +46,7 @@ export const actionAddToLibrary = register({
status: "unpublished",
elements: selectedElements.map(deepCopyElement),
created: Date.now(),
collectionId,
},
...items,
]);

49
packages/excalidraw/components/App.tsx

@ -343,7 +343,10 @@ import { @@ -343,7 +343,10 @@ import {
} from "../clipboard";
import { exportCanvas, loadFromBlob } from "../data";
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
import Library, {
distributeLibraryItemsOnSquareGrid,
libraryCollectionsAtom,
} from "../data/library";
import { restore, restoreElements } from "../data/restore";
import { getCenter, getDistance } from "../gesture";
import { History } from "../history";
@ -737,6 +740,13 @@ class App extends React.Component<AppProps, AppState> { @@ -737,6 +740,13 @@ class App extends React.Component<AppProps, AppState> {
applyDeltas: this.applyDeltas,
mutateElement: this.mutateElement,
updateLibrary: this.library.updateLibrary,
createLibraryCollection: this.library.createLibraryCollection,
deleteLibraryCollection: this.library.deleteLibraryCollection,
renameLibraryCollection: this.library.renameLibraryCollection,
moveUpCollection: this.library.moveUpCollection,
moveDownCollection: this.library.moveDownCollection,
getLibraryCollections: this.library.getCollections,
setLibraryCollection: this.library.setCollections,
addFiles: this.addFiles,
resetScene: this.resetScene,
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
@ -11803,6 +11813,27 @@ class App extends React.Component<AppProps, AppState> { @@ -11803,6 +11813,27 @@ class App extends React.Component<AppProps, AppState> {
return false;
};
private createAddToCollectionAction = (
collectionId: string,
collectionName: string,
): Action => {
// Create a custom label that includes the collection name
// We'll return a function that provides the final translated string
const baseLabel = t("labels.addToLibrary");
return {
...actionAddToLibrary,
label: () => `${baseLabel} "${collectionName}"`,
perform: (elements, appState, _, app) => {
return actionAddToLibrary.perform(
elements,
appState,
collectionId,
app,
);
},
};
};
private getContextMenuItems = (
type: "canvas" | "element",
): ContextMenuItems => {
@ -11862,6 +11893,20 @@ class App extends React.Component<AppProps, AppState> { @@ -11862,6 +11893,20 @@ class App extends React.Component<AppProps, AppState> {
]
: [];
// Get library collections for collection-specific "Add to library" options
const libraryCollections = editorJotaiStore.get(libraryCollectionsAtom);
const collectionActions: ContextMenuItems = libraryCollections.map(
(collection) =>
this.createAddToCollectionAction(collection.id, collection.name),
);
const addToLibrarySection: ContextMenuItems = [
actionAddToLibrary,
...(collectionActions.length > 0
? ([CONTEXT_MENU_SEPARATOR, ...collectionActions] as ContextMenuItems)
: []),
];
return [
CONTEXT_MENU_SEPARATOR,
actionCut,
@ -11886,7 +11931,7 @@ class App extends React.Component<AppProps, AppState> { @@ -11886,7 +11931,7 @@ class App extends React.Component<AppProps, AppState> {
actionWrapTextInContainer,
actionUngroup,
CONTEXT_MENU_SEPARATOR,
actionAddToLibrary,
...addToLibrarySection,
...zIndexActions,
CONTEXT_MENU_SEPARATOR,
actionFlipHorizontal,

20
packages/excalidraw/components/ContextMenu.tsx

@ -85,13 +85,21 @@ export const ContextMenu = React.memo( @@ -85,13 +85,21 @@ export const ContextMenu = React.memo(
let label = "";
if (item.label) {
if (typeof item.label === "function") {
label = t(
item.label(
elements,
appState,
actionManager.app,
) as unknown as TranslationKeys,
const labelResult = item.label(
elements,
appState,
actionManager.app,
);
// If the result contains a quote, it's likely a custom formatted string
// (e.g., "Add to library" "Collection Name"), so use it directly
if (
typeof labelResult === "string" &&
labelResult.includes('"')
) {
label = labelResult;
} else {
label = t(labelResult as unknown as TranslationKeys);
}
} else {
label = t(item.label as unknown as TranslationKeys);
}

1
packages/excalidraw/components/LibraryMenu.scss

@ -136,6 +136,7 @@ @@ -136,6 +136,7 @@
z-index: 1;
position: relative;
&--in-heading {
z-index: 2; // Higher z-index for main dropdown so it appears above collection dropdowns
margin-left: auto;
.dropdown-menu {
top: 100%;

6
packages/excalidraw/components/LibraryMenu.tsx

@ -88,10 +88,11 @@ const LibraryMenuContent = memo( @@ -88,10 +88,11 @@ const LibraryMenuContent = memo(
const [libraryItemsData] = useAtom(libraryItemsAtom);
const _onAddToLibrary = useCallback(
(elements: LibraryItem["elements"]) => {
(elements: LibraryItem["elements"], collectionId?: string) => {
const addToLibrary = async (
processedElements: LibraryItem["elements"],
libraryItems: LibraryItems,
targetCollectionId?: string,
) => {
trackEvent("element", "addToLibrary", "ui");
for (const type of LIBRARY_DISABLED_TYPES) {
@ -107,6 +108,7 @@ const LibraryMenuContent = memo( @@ -107,6 +108,7 @@ const LibraryMenuContent = memo(
elements: processedElements,
id: randomId(),
created: Date.now(),
collectionId: targetCollectionId,
},
...libraryItems,
];
@ -115,7 +117,7 @@ const LibraryMenuContent = memo( @@ -115,7 +117,7 @@ const LibraryMenuContent = memo(
setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
});
};
addToLibrary(elements, libraryItemsData.libraryItems);
addToLibrary(elements, libraryItemsData.libraryItems, collectionId);
},
[onAddToLibrary, library, setAppState, libraryItemsData.libraryItems],
);

110
packages/excalidraw/components/LibraryMenuHeaderContent.tsx

@ -20,9 +20,12 @@ import { ToolButton } from "./ToolButton"; @@ -20,9 +20,12 @@ import { ToolButton } from "./ToolButton";
import Trans from "./Trans";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import {
ArrowRightIcon,
DotsIcon,
ExportIcon,
LoadIcon,
pencilIcon,
PlusIcon,
publishIcon,
TrashIcon,
} from "./icons";
@ -211,6 +214,24 @@ export const LibraryDropdownMenuButton: React.FC<{ @@ -211,6 +214,24 @@ export const LibraryDropdownMenuButton: React.FC<{
{t("buttons.load")}
</DropdownMenu.Item>
)}
<DropdownMenu.Item
onSelect={async () => {
// prompt for a collection name and create a new library collection
const name = window.prompt("Create library");
if (!name) {
return;
}
try {
await library.createLibraryCollection(name);
} catch (error: any) {
setAppState({ errorMessage: error?.message || String(error) });
}
}}
icon={PlusIcon}
data-testid="lib-dropdown--create"
>
{"Create library"}
</DropdownMenu.Item>
{!!items.length && (
<DropdownMenu.Item
onSelect={onLibraryExport}
@ -274,6 +295,95 @@ export const LibraryDropdownMenuButton: React.FC<{ @@ -274,6 +295,95 @@ export const LibraryDropdownMenuButton: React.FC<{
);
};
export const CollectionHeaderDropdown: React.FC<{
collectionName: string;
onRename: () => void;
onDelete: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({
collectionName,
onRename,
onDelete,
onMoveUp,
onMoveDown,
canMoveUp = false,
canMoveDown = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div
className={clsx("library-menu-dropdown-container", {
"collection-dropdown-open": isOpen,
})}
onClick={(e) => {
e.stopPropagation();
}}
onPointerDown={(e) => {
e.stopPropagation();
}}
>
<DropdownMenu open={isOpen}>
<DropdownMenu.Trigger
onToggle={() => setIsOpen(!isOpen)}
className="collection-header-dropdown-trigger"
>
{DotsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsOpen(false)}
onSelect={() => setIsOpen(false)}
className="collection-header-menu"
>
<DropdownMenu.Item
onSelect={onRename}
icon={pencilIcon}
data-testid="collection-dropdown--rename"
>
Rename
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={onDelete}
icon={TrashIcon}
data-testid="collection-dropdown--delete"
>
{t("labels.delete")}
</DropdownMenu.Item>
{onMoveUp && canMoveUp && (
<DropdownMenu.Item
onSelect={onMoveUp}
icon={
<div style={{ transform: "rotate(-90deg)" }}>
{ArrowRightIcon}
</div>
}
data-testid="collection-dropdown--move-up"
>
Move Up
</DropdownMenu.Item>
)}
{onMoveDown && canMoveDown && (
<DropdownMenu.Item
onSelect={onMoveDown}
icon={
<div style={{ transform: "rotate(90deg)" }}>
{ArrowRightIcon}
</div>
}
data-testid="collection-dropdown--move-down"
>
Move Down
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
</div>
);
};
export const LibraryDropdownMenu = ({
selectedItems,
onSelectItems,

96
packages/excalidraw/components/LibraryMenuItems.scss

@ -95,6 +95,36 @@ @@ -95,6 +95,36 @@
margin-top: 2rem;
}
&--clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s ease-in-out;
&:hover {
opacity: 0.8;
}
&:active {
opacity: 0.6;
}
}
&__arrow {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
transition: transform 0.2s ease-in-out;
svg {
width: 1rem;
height: 1rem;
stroke-width: 2;
}
}
&__hint {
margin-left: auto;
font-size: 10px;
@ -151,4 +181,70 @@ @@ -151,4 +181,70 @@
min-height: 3.75rem;
width: 100%;
}
.collection-header-dropdown-trigger {
margin-left: 0.5rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0;
border: none !important;
outline: none !important;
cursor: pointer;
transition: none;
color: var(--color-primary);
position: relative;
z-index: 1;
/* ensure no visible background on hover/active */
background: transparent !important;
&:hover,
&:active,
&:focus {
background: transparent !important;
box-shadow: none !important;
border: none !important;
outline: none !important;
}
svg {
width: 1rem;
height: 1rem;
display: block;
}
}
&.theme--dark {
.collection-header-dropdown-trigger,
.collection-header-dropdown-trigger:hover,
.collection-header-dropdown-trigger:active,
.collection-header-dropdown-trigger:focus {
background: transparent !important;
}
}
.collection-header-menu {
position: relative;
}
.library-menu-items-container__header {
.library-menu-dropdown-container {
position: relative;
.dropdown-menu {
position: absolute;
right: 0;
left: auto;
top: 100%;
margin-top: 0.25rem;
}
// When a collection dropdown is open, give it a higher z-index
// so it appears above other collection dropdowns
&.collection-dropdown-open {
z-index: 2;
}
}
}
}

386
packages/excalidraw/components/LibraryMenuItems.tsx

@ -17,9 +17,14 @@ import { deburr } from "../deburr"; @@ -17,9 +17,14 @@ import { deburr } from "../deburr";
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
import { useScrollPosition } from "../hooks/useScrollPosition";
import { t } from "../i18n";
import { libraryCollectionsAtom } from "../data/library";
import { useAtom } from "../editor-jotai";
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
import {
CollectionHeaderDropdown,
LibraryDropdownMenu,
} from "./LibraryMenuHeaderContent";
import {
LibraryMenuSection,
LibraryMenuSectionGrid,
@ -32,9 +37,10 @@ import "./LibraryMenuItems.scss"; @@ -32,9 +37,10 @@ import "./LibraryMenuItems.scss";
import { TextField } from "./TextField";
import { useEditorInterface } from "./App";
import { useApp, useEditorInterface, useExcalidrawSetAppState } from "./App";
import { Button } from "./Button";
import { collapseDownIcon, collapseUpIcon } from "./icons";
import type { ExcalidrawLibraryIds } from "../data/types";
@ -52,6 +58,12 @@ const ITEMS_RENDERED_PER_BATCH = 17; @@ -52,6 +58,12 @@ const ITEMS_RENDERED_PER_BATCH = 17;
// speed it up
const CACHED_ITEMS_RENDERED_PER_BATCH = 64;
const COLLECTION_COLLAPSE_STATE_KEY =
"excalidraw-library-collection-collapse-state";
const PERSONAL_LIBRARY_COLLAPSE_KEY = "excalidraw-library-personal-collapsed";
const EXCALIDRAW_LIBRARY_COLLAPSE_KEY =
"excalidraw-library-excalidraw-collapsed";
export default function LibraryMenuItems({
isLoading,
libraryItems,
@ -68,13 +80,18 @@ export default function LibraryMenuItems({ @@ -68,13 +80,18 @@ export default function LibraryMenuItems({
libraryItems: LibraryItems;
pendingElements: LibraryItem["elements"];
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
onAddToLibrary: (
elements: LibraryItem["elements"],
collectionId?: string,
) => void;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
theme: UIAppState["theme"];
id: string;
selectedItems: LibraryItem["id"][];
onSelectItems: (id: LibraryItem["id"][]) => void;
}) {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const editorInterface = useEditorInterface();
const libraryContainerRef = useRef<HTMLDivElement>(null);
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
@ -93,6 +110,107 @@ export default function LibraryMenuItems({ @@ -93,6 +110,107 @@ export default function LibraryMenuItems({
const [searchInputValue, setSearchInputValue] = useState("");
// Load Personal Library collapse state
const [isPersonalLibraryCollapsed, setIsPersonalLibraryCollapsed] = useState(
() => {
try {
const saved = localStorage.getItem(PERSONAL_LIBRARY_COLLAPSE_KEY);
return saved === "true";
} catch (error) {
console.warn("Failed to load personal library collapse state:", error);
return false;
}
},
);
// Load Excalidraw Library collapse state
const [isExcalidrawLibraryCollapsed, setIsExcalidrawLibraryCollapsed] =
useState(() => {
try {
const saved = localStorage.getItem(EXCALIDRAW_LIBRARY_COLLAPSE_KEY);
return saved === "true";
} catch (error) {
console.warn(
"Failed to load excalidraw library collapse state:",
error,
);
return false;
}
});
const [libraryCollections] = useAtom(libraryCollectionsAtom);
// Load library collections collapse state
const [customCollectionCollapsed, setCustomCollectionCollapsed] = useState<
Record<string, boolean>
>(() => {
try {
const saved = localStorage.getItem(COLLECTION_COLLAPSE_STATE_KEY);
if (saved) {
return JSON.parse(saved);
}
} catch (error) {
console.warn("Failed to load collection collapse state:", error);
}
return {};
});
// Save Personal Library collapse state to localStorage
useEffect(() => {
try {
localStorage.setItem(
PERSONAL_LIBRARY_COLLAPSE_KEY,
String(isPersonalLibraryCollapsed),
);
} catch (error) {
console.warn("Failed to save personal library collapse state:", error);
}
}, [isPersonalLibraryCollapsed]);
// Save Excalidraw Library collapse state to localStorage
useEffect(() => {
try {
localStorage.setItem(
EXCALIDRAW_LIBRARY_COLLAPSE_KEY,
String(isExcalidrawLibraryCollapsed),
);
} catch (error) {
console.warn("Failed to save excalidraw library collapse state:", error);
}
}, [isExcalidrawLibraryCollapsed]);
// Save library collections collapse state to localStorage
useEffect(() => {
try {
localStorage.setItem(
COLLECTION_COLLAPSE_STATE_KEY,
JSON.stringify(customCollectionCollapsed),
);
} catch (error) {
console.warn("Failed to save collection collapse state:", error);
}
}, [customCollectionCollapsed]);
// Clean up stale library collections IDs from localStorage
useEffect(() => {
const collectionIds = new Set(libraryCollections.map((c) => c.id));
const hasStaleKeys = Object.keys(customCollectionCollapsed).some(
(id) => !collectionIds.has(id),
);
if (hasStaleKeys) {
setCustomCollectionCollapsed((prev) => {
const cleaned: Record<string, boolean> = {};
for (const id of collectionIds) {
if (prev[id] !== undefined) {
cleaned[id] = prev[id];
}
}
return cleaned;
});
}
}, [libraryCollections, customCollectionCollapsed]);
const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
@ -111,9 +229,27 @@ export default function LibraryMenuItems({ @@ -111,9 +229,27 @@ export default function LibraryMenuItems({
});
}, [libraryItems, searchInputValue]);
// Get items for each collection
const collectionItems = useMemo(() => {
const itemsMap: Record<string, LibraryItems> = {};
libraryCollections.forEach((collection) => {
itemsMap[collection.id] = libraryItems.filter(
(item) => item.collectionId === collection.id,
);
});
return itemsMap;
}, [libraryCollections, libraryItems]);
// Unpublished items that don't belong to any collection
const unpublishedItems = useMemo(
() => libraryItems.filter((item) => item.status !== "published"),
[libraryItems],
() =>
libraryItems.filter(
(item) =>
item.status !== "published" &&
(!item.collectionId ||
!libraryCollections.some((c) => c.id === item.collectionId)),
),
[libraryItems, libraryCollections],
);
const publishedItems = useMemo(
@ -124,7 +260,15 @@ export default function LibraryMenuItems({ @@ -124,7 +260,15 @@ export default function LibraryMenuItems({
const onItemSelectToggle = useCallback(
(id: LibraryItem["id"], event: React.MouseEvent) => {
const shouldSelect = !selectedItems.includes(id);
const orderedItems = [...unpublishedItems, ...publishedItems];
// Build ordered items list: unpublished, then collections, then published
const collectionItemsList = libraryCollections.flatMap(
(collection) => collectionItems[collection.id] || [],
);
const orderedItems = [
...unpublishedItems,
...collectionItemsList,
...publishedItems,
];
if (shouldSelect) {
if (event.shiftKey && lastSelectedItem) {
const rangeStart = orderedItems.findIndex(
@ -169,6 +313,8 @@ export default function LibraryMenuItems({ @@ -169,6 +313,8 @@ export default function LibraryMenuItems({
publishedItems,
selectedItems,
unpublishedItems,
libraryCollections,
collectionItems,
],
);
@ -231,9 +377,12 @@ export default function LibraryMenuItems({ @@ -231,9 +377,12 @@ export default function LibraryMenuItems({
[selectedItems],
);
const onAddToLibraryClick = useCallback(() => {
onAddToLibrary(pendingElements);
}, [pendingElements, onAddToLibrary]);
const onAddToLibraryClick = useCallback(
(collectionId?: string) => {
onAddToLibrary(pendingElements, collectionId);
},
[pendingElements, onAddToLibrary],
);
const onItemClick = useCallback(
(id: LibraryItem["id"] | null) => {
@ -262,56 +411,213 @@ export default function LibraryMenuItems({ @@ -262,56 +411,213 @@ export default function LibraryMenuItems({
<>
{!IS_LIBRARY_EMPTY && (
<div className="library-menu-items-container__header">
{t("labels.personalLib")}
<span
onClick={() =>
setIsPersonalLibraryCollapsed(!isPersonalLibraryCollapsed)
}
style={{
display: "flex",
alignItems: "center",
flex: 1,
cursor: "pointer",
}}
>
<span>{t("labels.personalLib")}</span>
<span className="library-menu-items-container__header__arrow">
{isPersonalLibraryCollapsed ? collapseDownIcon : collapseUpIcon}
</span>
</span>
</div>
)}
{!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
{!publishedItems.length && (
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
{!isPersonalLibraryCollapsed &&
(!pendingElements.length && !unpublishedItems.length ? (
<div className="library-menu-items__no-items">
{!publishedItems.length && (
<div className="library-menu-items__no-items__label">
{t("library.noItems")}
</div>
)}
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
)}
<div className="library-menu-items__no-items__hint">
{publishedItems.length > 0
? t("library.hint_emptyPrivateLibrary")
: t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={() => onAddToLibraryClick(undefined)}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onAddToLibraryClick}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={unpublishedItems}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
</LibraryMenuSectionGrid>
)}
</LibraryMenuSectionGrid>
))}
{/* Custom Collections */}
{libraryCollections.map((collection, index) => {
const items = collectionItems[collection.id] || [];
const isCollapsed = customCollectionCollapsed[collection.id] ?? false;
return (
<React.Fragment key={collection.id}>
<div
className="library-menu-items-container__header"
style={{ marginTop: "0.75rem" }}
>
<span
onClick={() =>
setCustomCollectionCollapsed((prev) => ({
...prev,
[collection.id]: !isCollapsed,
}))
}
style={{
display: "flex",
alignItems: "center",
flex: 1,
cursor: "pointer",
}}
>
<span>{collection.name}</span>
<span className="library-menu-items-container__header__arrow">
{isCollapsed ? collapseDownIcon : collapseUpIcon}
</span>
</span>
<CollectionHeaderDropdown
collectionName={collection.name}
onRename={async () => {
const newName = window.prompt(
"Rename collection",
collection.name,
);
if (
newName &&
newName.trim() &&
newName !== collection.name
) {
try {
await app.library.renameLibraryCollection(
collection.id,
newName.trim(),
);
} catch (error: any) {
setAppState({
errorMessage: error?.message || String(error),
});
}
}
}}
onDelete={async () => {
if (
window.confirm(`Delete "${collection.name}" collection?`)
) {
try {
await app.library.deleteLibraryCollection(collection.id);
} catch (error: any) {
setAppState({
errorMessage: error?.message || String(error),
});
}
}
}}
onMoveUp={async () => {
try {
await app.library.moveUpCollection(collection.id);
} catch (error: any) {
setAppState({
errorMessage: error?.message || String(error),
});
}
}}
onMoveDown={async () => {
try {
await app.library.moveDownCollection(collection.id);
} catch (error: any) {
setAppState({
errorMessage: error?.message || String(error),
});
}
}}
canMoveUp={index > 0}
canMoveDown={index < libraryCollections.length - 1}
/>
</div>
{!isCollapsed &&
(items.length === 0 && !pendingElements.length ? (
<div className="library-menu-items__no-items">
<div className="library-menu-items__no-items__hint">
{t("library.hint_emptyLibrary")}
</div>
</div>
) : (
<LibraryMenuSectionGrid>
{pendingElements.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={[{ id: null, elements: pendingElements }]}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={() => onAddToLibraryClick(collection.id)}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
{items.length > 0 && (
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}
items={items}
onItemSelectToggle={onItemSelectToggle}
onItemDrag={onItemDrag}
onClick={onItemClick}
isItemSelected={isItemSelected}
svgCache={svgCache}
/>
)}
</LibraryMenuSectionGrid>
))}
</React.Fragment>
);
})}
{publishedItems.length > 0 && (
<div
className="library-menu-items-container__header"
style={{ marginTop: "0.75rem" }}
>
{t("labels.excalidrawLib")}
<span
onClick={() =>
setIsExcalidrawLibraryCollapsed(!isExcalidrawLibraryCollapsed)
}
style={{
display: "flex",
alignItems: "center",
flex: 1,
cursor: "pointer",
}}
>
<span>{t("labels.excalidrawLib")}</span>
<span className="library-menu-items-container__header__arrow">
{isExcalidrawLibraryCollapsed ? collapseDownIcon : collapseUpIcon}
</span>
</span>
</div>
)}
{publishedItems.length > 0 && (
{publishedItems.length > 0 && !isExcalidrawLibraryCollapsed && (
<LibraryMenuSectionGrid>
<LibraryMenuSection
itemsRenderedPerBatch={itemsRenderedPerBatch}

349
packages/excalidraw/data/library.ts

@ -15,6 +15,7 @@ import { @@ -15,6 +15,7 @@ import {
toValidURL,
Queue,
Emitter,
randomId,
} from "@excalidraw/common";
import { hashElementsVersion, hashString } from "@excalidraw/element";
@ -42,6 +43,8 @@ import type { @@ -42,6 +43,8 @@ import type {
ExcalidrawImperativeAPI,
LibraryItemsSource,
LibraryItems_anyVersion,
LibraryCollections,
LibraryCollection,
} from "../types";
/**
@ -67,11 +70,20 @@ type LibraryUpdate = { @@ -67,11 +70,20 @@ type LibraryUpdate = {
// an object so that we can later add more properties to it without breaking,
// such as schema version
export type LibraryPersistedData = { libraryItems: LibraryItems };
export type LibraryPersistedData = {
libraryItems: LibraryItems;
libraryCollections: LibraryCollections;
};
const onLibraryUpdateEmitter = new Emitter<
[update: LibraryUpdate, libraryItems: LibraryItems]
>();
const onLibraryCollectionsUpdateEmitter = new Emitter<
[collections: LibraryCollections]
>();
let latestLibraryItemsSnapshot: LibraryItems = [];
let latestLibraryCollectionsSnapshot: LibraryCollections = [];
export type LibraryAdatapterSource = "load" | "save";
@ -89,7 +101,10 @@ export interface LibraryPersistenceAdapter { @@ -89,7 +101,10 @@ export interface LibraryPersistenceAdapter {
* purposes, in which case host app can implement more aggressive caching.
*/
source: LibraryAdatapterSource;
}): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>;
}): MaybePromise<{
libraryItems: LibraryItems_anyVersion;
libraryCollections: LibraryCollections;
} | null>;
/** Should persist to the database as is (do no change the data structure). */
save(libraryData: LibraryPersistedData): MaybePromise<void>;
}
@ -113,9 +128,15 @@ export const libraryItemsAtom = atom<{ @@ -113,9 +128,15 @@ export const libraryItemsAtom = atom<{
libraryItems: LibraryItems;
}>({ status: "loaded", isInitialized: false, libraryItems: [] });
export const libraryCollectionsAtom = atom<LibraryCollections>([]);
const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems =>
cloneJSON(libraryItems);
const cloneLibraryCollections = (
collections: LibraryCollections,
): LibraryCollections => cloneJSON(collections);
/**
* checks if library item does not exist already in current library
*/
@ -201,18 +222,33 @@ class Library { @@ -201,18 +222,33 @@ class Library {
/** snapshot of library items since last onLibraryChange call */
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
private libraryCollections: LibraryCollections = [];
private app: App;
constructor(app: App) {
this.app = app;
this.libraryCollections = [];
latestLibraryCollectionsSnapshot = cloneLibraryCollections(
this.libraryCollections,
);
}
private updateQueue: Promise<LibraryItems>[] = [];
private collectionsUpdateQueue: Promise<LibraryCollections>[] = [];
private getLastUpdateTask = (): Promise<LibraryItems> | undefined => {
return this.updateQueue[this.updateQueue.length - 1];
};
private getLastCollectionsUpdateTask = ():
| Promise<LibraryCollections>
| undefined => {
return this.collectionsUpdateQueue[this.collectionsUpdateQueue.length - 1];
};
private notifyListeners = () => {
if (this.updateQueue.length > 0) {
editorJotaiStore.set(libraryItemsAtom, (s) => ({
@ -231,6 +267,7 @@ class Library { @@ -231,6 +267,7 @@ class Library {
this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
const nextLibraryItems = cloneLibraryItems(this.currLibraryItems);
latestLibraryItemsSnapshot = nextLibraryItems;
this.app.props.onLibraryChange?.(nextLibraryItems);
@ -248,7 +285,11 @@ class Library { @@ -248,7 +285,11 @@ class Library {
/** call on excalidraw instance unmount */
destroy = () => {
this.updateQueue = [];
this.collectionsUpdateQueue = [];
this.currLibraryItems = [];
this.libraryCollections = [];
latestLibraryItemsSnapshot = [];
latestLibraryCollectionsSnapshot = [];
editorJotaiStore.set(libraryItemSvgsCache, new Map());
// TODO uncomment after/if we make jotai store scoped to each excal instance
// jotaiStore.set(libraryItemsAtom, {
@ -262,6 +303,150 @@ class Library { @@ -262,6 +303,150 @@ class Library {
return this.setLibrary([]);
};
createLibraryCollection = async (
name: string,
color?: string,
): Promise<LibraryCollection> => {
const collection: LibraryCollection = {
id: randomId(),
name,
created: Date.now(),
items: [],
color: color || "#000000",
};
await this.setCollections((current) => [...current, collection]);
return collection;
};
deleteLibraryCollection = async (collectionId: string): Promise<void> => {
await this.setCollections((current) =>
current.filter((collection) => collection.id !== collectionId),
);
// Also delete all items that belong to this collection
const currentItems = await this.getLatestLibrary();
const itemsWithoutCollection = currentItems.filter(
(item) => item.collectionId !== collectionId,
);
await this.setLibrary(itemsWithoutCollection);
};
renameLibraryCollection = async (
collectionId: string,
newName: string,
): Promise<void> => {
await this.setCollections((current) =>
current.map((collection) =>
collection.id === collectionId
? { ...collection, name: newName }
: collection,
),
);
};
moveUpCollection = async (collectionId: string): Promise<void> => {
await this.setCollections((current) => {
const index = current.findIndex((c) => c.id === collectionId);
if (index <= 0) {
return current;
}
const newCollections = [...current];
[newCollections[index - 1], newCollections[index]] = [
newCollections[index],
newCollections[index - 1],
];
return newCollections;
});
};
moveDownCollection = async (collectionId: string): Promise<void> => {
await this.setCollections((current) => {
const index = current.findIndex((c) => c.id === collectionId);
if (index === -1 || index >= current.length - 1) {
return current;
}
const newCollections = [...current];
[newCollections[index], newCollections[index + 1]] = [
newCollections[index + 1],
newCollections[index],
];
return newCollections;
});
};
setCollections = (
collections:
| LibraryCollections
| Promise<LibraryCollections>
| ((
current: LibraryCollections,
) => LibraryCollections | Promise<LibraryCollections>),
): Promise<LibraryCollections> => {
const task = new Promise<LibraryCollections>(async (resolve, reject) => {
try {
await this.getLastCollectionsUpdateTask();
if (typeof collections === "function") {
collections = collections(this.libraryCollections);
}
this.libraryCollections = cloneLibraryCollections(await collections);
latestLibraryCollectionsSnapshot = cloneLibraryCollections(
this.libraryCollections,
);
editorJotaiStore.set(
libraryCollectionsAtom,
cloneLibraryCollections(this.libraryCollections),
);
try {
const nextCollections = cloneLibraryCollections(
this.libraryCollections,
);
this.app.props.onLibraryCollectionsChange?.(nextCollections);
} catch (error) {
console.error(error);
}
const collectionsToEmit = cloneLibraryCollections(
this.libraryCollections,
);
onLibraryCollectionsUpdateEmitter.trigger(collectionsToEmit);
resolve(this.libraryCollections);
} catch (error: any) {
reject(error);
}
}).finally(() => {
this.collectionsUpdateQueue = this.collectionsUpdateQueue.filter(
(_task) => _task !== task,
);
});
this.collectionsUpdateQueue.push(task);
return task;
};
getCollections = async (): Promise<LibraryCollections> => {
return new Promise(async (resolve) => {
try {
const collections = await (this.getLastCollectionsUpdateTask() ||
this.libraryCollections);
if (this.collectionsUpdateQueue.length > 0) {
resolve(this.getCollections());
} else {
resolve(cloneLibraryCollections(collections));
}
} catch (error) {
return resolve(cloneLibraryCollections(this.libraryCollections));
}
});
};
/**
* @returns latest cloned libraryItems. Awaits all in-progress updates first.
*/
@ -567,6 +752,28 @@ class AdapterTransaction { @@ -567,6 +752,28 @@ class AdapterTransaction {
return task();
}
static async getLibraryCollections(
adapter: LibraryPersistenceAdapter,
source: LibraryAdatapterSource,
_queue = true,
): Promise<LibraryCollections> {
const task = () =>
new Promise<LibraryCollections>(async (resolve, reject) => {
try {
const data = await adapter.load({ source });
resolve(data?.libraryCollections || []);
} catch (error: any) {
reject(error);
}
});
if (_queue) {
return AdapterTransaction.queue.push(task);
}
return task();
}
static run = async <T>(
adapter: LibraryPersistenceAdapter,
fn: (transaction: AdapterTransaction) => Promise<T>,
@ -662,7 +869,10 @@ const persistLibraryUpdate = async ( @@ -662,7 +869,10 @@ const persistLibraryUpdate = async (
const version = getLibraryItemsHash(nextLibraryItems);
if (version !== lastSavedLibraryItemsHash) {
await adapter.save({ libraryItems: nextLibraryItems });
await adapter.save({
libraryItems: nextLibraryItems,
libraryCollections: latestLibraryCollectionsSnapshot,
});
}
lastSavedLibraryItemsHash = version;
@ -674,6 +884,23 @@ const persistLibraryUpdate = async ( @@ -674,6 +884,23 @@ const persistLibraryUpdate = async (
}
};
const persistLibraryCollections = async (
adapter: LibraryPersistenceAdapter,
collections: LibraryCollections,
) => {
try {
librarySaveCounter++;
await AdapterTransaction.run(adapter, async () => {
await adapter.save({
libraryItems: latestLibraryItemsSnapshot,
libraryCollections: collections,
});
});
} finally {
librarySaveCounter--;
}
};
export const useHandleLibrary = (
opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null;
@ -838,7 +1065,10 @@ export const useHandleLibrary = ( @@ -838,7 +1065,10 @@ export const useHandleLibrary = (
const adapter = optsRef.current.adapter;
const migrationAdapter = optsRef.current.migrationAdapter;
const initDataPromise = resolvablePromise<LibraryItems | null>();
const initDataPromise = resolvablePromise<{
items: LibraryItems;
collections: LibraryCollections | null;
} | null>();
// migrate from old data source if needed
// (note, if `migrate` function is defined, we always migrate even
@ -852,11 +1082,23 @@ export const useHandleLibrary = ( @@ -852,11 +1082,23 @@ export const useHandleLibrary = (
.then(async (libraryData) => {
let restoredData: LibraryItems | null = null;
try {
// Load collections from the regular adapter
const collections =
await AdapterTransaction.getLibraryCollections(
adapter,
"load",
false,
);
// if no library data to migrate, assume no migration needed
// and skip persisting to new data store, as well as well
// clearing the old store via `migrationAdapter.clear()`
if (!libraryData) {
return AdapterTransaction.getLibraryItems(adapter, "load");
const items = await AdapterTransaction.getLibraryItems(
adapter,
"load",
);
return { items, collections };
}
restoredData = restoreLibraryItems(
@ -878,33 +1120,72 @@ export const useHandleLibrary = ( @@ -878,33 +1120,72 @@ export const useHandleLibrary = (
);
}
// migration suceeded, load migrated data
return nextItems;
return { items: nextItems, collections };
} catch (error: any) {
console.error(
`couldn't migrate legacy library data: ${error.message}`,
);
// migration failed, load data from previous store, if any
return restoredData;
// Still try to load collections from adapter
let collections: LibraryCollections = [];
try {
collections = await AdapterTransaction.getLibraryCollections(
adapter,
"load",
false,
);
} catch {
// ignore errors loading collections
}
return restoredData
? { items: restoredData, collections }
: { items: [], collections };
}
})
// errors caught during `migrationAdapter.load()`
.catch((error: any) => {
.catch(async (error: any) => {
console.error(`error during library migration: ${error.message}`);
// as a default, load latest library from current data source
return AdapterTransaction.getLibraryItems(adapter, "load");
const items = await AdapterTransaction.getLibraryItems(
adapter,
"load",
);
// Also load collections from adapter
let collections: LibraryCollections = [];
try {
collections = await AdapterTransaction.getLibraryCollections(
adapter,
"load",
false,
);
} catch {
// ignore errors loading collections
}
return { items, collections };
}),
);
} else {
initDataPromise.resolve(
promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"),
Promise.all([
AdapterTransaction.getLibraryItems(adapter, "load"),
AdapterTransaction.getLibraryCollections(adapter, "load", false),
]).then(([items, collections]) => ({
items,
collections,
})),
);
}
// load initial (or migrated) library
const initCollectionsPromise = initDataPromise.then(
(data) => data?.collections,
);
excalidrawAPI
.updateLibrary({
libraryItems: initDataPromise.then((libraryItems) => {
const _libraryItems = libraryItems || [];
libraryItems: initDataPromise.then((data) => {
const _libraryItems = data?.items || [];
latestLibraryItemsSnapshot = cloneLibraryItems(_libraryItems);
lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems);
return _libraryItems;
}),
@ -916,8 +1197,25 @@ export const useHandleLibrary = ( @@ -916,8 +1197,25 @@ export const useHandleLibrary = (
.finally(() => {
isLibraryLoadedRef.current = true;
});
initCollectionsPromise.then(async (collections) => {
if (optsRef.current.excalidrawAPI?.setLibraryCollection) {
const loadedCollections = collections || [];
if (loadedCollections.length > 0) {
await optsRef.current.excalidrawAPI
.setLibraryCollection(loadedCollections)
.catch((error) => console.error(error));
} else {
// Set empty array to ensure atom is initialized
await optsRef.current.excalidrawAPI
.setLibraryCollection([])
.catch((error) => console.error(error));
}
}
});
}
// ---------------------------------------------- data source datapter -----
// ---------------------------------------------- data source adapter -----
window.addEventListener(EVENT.HASHCHANGE, onHashChange);
return () => {
@ -979,6 +1277,30 @@ export const useHandleLibrary = ( @@ -979,6 +1277,30 @@ export const useHandleLibrary = (
},
);
const unsubOnLibraryCollectionsUpdate =
onLibraryCollectionsUpdateEmitter.on(async (collections) => {
const isLoaded = isLibraryLoadedRef.current;
const adapter =
("adapter" in optsRef.current && optsRef.current.adapter) || null;
if (!adapter) {
return;
}
try {
await persistLibraryCollections(adapter, collections);
} catch (error: any) {
console.error(
`couldn't persist library collections update: ${error.message}`,
);
if (isLoaded && optsRef.current.excalidrawAPI) {
optsRef.current.excalidrawAPI.updateScene({
appState: {
errorMessage: t("errors.saveLibraryError"),
},
});
}
}
});
const onUnload = (event: Event) => {
if (librarySaveCounter) {
preventUnload(event);
@ -990,6 +1312,7 @@ export const useHandleLibrary = ( @@ -990,6 +1312,7 @@ export const useHandleLibrary = (
return () => {
window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload);
unsubOnLibraryUpdate();
unsubOnLibraryCollectionsUpdate();
lastSavedLibraryItemsHash = 0;
librarySaveCounter = 0;
};

395
packages/excalidraw/tests/libraryCollections.test.tsx

@ -0,0 +1,395 @@ @@ -0,0 +1,395 @@
import { act, queryByTestId, waitFor } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { fireEvent, render } from "./test-utils";
import type { LibraryItems } from "../types";
const { h } = window;
describe("library collections", () => {
beforeEach(async () => {
await render(<Excalidraw />);
await act(() => {
return h.app.library.resetLibrary();
});
// Clear collections
await act(async () => {
const collections = await h.app.library.getCollections();
for (const collection of collections) {
await h.app.library.deleteLibraryCollection(collection.id);
}
});
localStorage.clear();
});
afterEach(async () => {
await act(() => {
return h.app.library.resetLibrary();
});
localStorage.clear();
});
describe("creating collections", () => {
it("should create a new collection", async () => {
const collection = await h.app.library.createLibraryCollection(
"Test Collection",
);
expect(collection).toMatchObject({
id: expect.any(String),
name: "Test Collection",
created: expect.any(Number),
color: expect.any(String),
});
expect(collection.items).toEqual([]);
const collections = await h.app.library.getCollections();
expect(collections).toHaveLength(1);
expect(collections[0].id).toBe(collection.id);
expect(collections[0].name).toBe("Test Collection");
});
it("should create multiple collections", async () => {
await h.app.library.createLibraryCollection("Collection 1");
await h.app.library.createLibraryCollection("Collection 2");
const collections = await h.app.library.getCollections();
expect(collections).toHaveLength(2);
expect(collections.map((c) => c.name)).toEqual(
expect.arrayContaining(["Collection 1", "Collection 2"]),
);
});
});
describe("deleting collections", () => {
it("should delete a collection", async () => {
const collection = await h.app.library.createLibraryCollection(
"To Delete",
);
let collections = await h.app.library.getCollections();
expect(collections).toHaveLength(1);
await h.app.library.deleteLibraryCollection(collection.id);
collections = await h.app.library.getCollections();
expect(collections).toHaveLength(0);
});
it("should delete collection and its items", async () => {
const collection = await h.app.library.createLibraryCollection(
"Collection with Items",
);
// Create library items with collectionId
const rectangle = API.createElement({
id: "rect1",
type: "rectangle",
});
const circle = API.createElement({
id: "circle1",
type: "ellipse",
});
const libraryItems: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [rectangle],
created: Date.now(),
collectionId: collection.id,
},
{
id: "item2",
status: "unpublished",
elements: [circle],
created: Date.now(),
collectionId: collection.id,
},
{
id: "item3",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
// No collectionId - should remain
},
];
await h.app.library.setLibrary(libraryItems);
let allItems = await h.app.library.getLatestLibrary();
expect(allItems).toHaveLength(3);
// Delete the collection
await h.app.library.deleteLibraryCollection(collection.id);
// Collection should be deleted
const collections = await h.app.library.getCollections();
expect(collections).toHaveLength(0);
// Items with collectionId should be deleted
allItems = await h.app.library.getLatestLibrary();
expect(allItems).toHaveLength(1);
expect(allItems[0].id).toBe("item3");
expect(allItems[0].collectionId).toBeUndefined();
});
it("should handle deleting non-existent collection gracefully", async () => {
// Should not throw
await expect(
h.app.library.deleteLibraryCollection("non-existent-id"),
).resolves.not.toThrow();
});
});
describe("adding items to collections", () => {
it("should add item to collection with collectionId", async () => {
const collection = await h.app.library.createLibraryCollection(
"My Collection",
);
const rectangle = API.createElement({
type: "rectangle",
});
const libraryItems: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [rectangle],
created: Date.now(),
collectionId: collection.id,
},
];
await h.app.library.setLibrary(libraryItems);
const items = await h.app.library.getLatestLibrary();
expect(items).toHaveLength(1);
expect(items[0].collectionId).toBe(collection.id);
});
it("should add multiple items to same collection", async () => {
const collection = await h.app.library.createLibraryCollection(
"Shared Collection",
);
const items: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
collectionId: collection.id,
},
{
id: "item2",
status: "unpublished",
elements: [API.createElement({ type: "ellipse" })],
created: Date.now(),
collectionId: collection.id,
},
];
await h.app.library.setLibrary(items);
const allItems = await h.app.library.getLatestLibrary();
expect(allItems).toHaveLength(2);
expect(
allItems.every((item) => item.collectionId === collection.id),
).toBe(true);
});
});
describe("getting collections", () => {
it("should return empty array when no collections exist", async () => {
const collections = await h.app.library.getCollections();
expect(collections).toEqual([]);
});
it("should return all collections", async () => {
await h.app.library.createLibraryCollection("Collection 1");
await h.app.library.createLibraryCollection("Collection 2");
await h.app.library.createLibraryCollection("Collection 3");
const collections = await h.app.library.getCollections();
expect(collections).toHaveLength(3);
});
});
describe("collection persistence", () => {
it("should persist collections across app re-renders", async () => {
const collection = await h.app.library.createLibraryCollection(
"Persistent Collection",
);
// Simulate re-render by getting collections again
const collections = await h.app.library.getCollections();
expect(collections).toHaveLength(1);
expect(collections[0].id).toBe(collection.id);
expect(collections[0].name).toBe("Persistent Collection");
});
it("should persist items with collectionId", async () => {
const collection = await h.app.library.createLibraryCollection(
"Collection",
);
const items: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
collectionId: collection.id,
},
];
await h.app.library.setLibrary(items);
// Get items again to verify persistence
const allItems = await h.app.library.getLatestLibrary();
expect(allItems).toHaveLength(1);
expect(allItems[0].collectionId).toBe(collection.id);
});
});
describe("UI integration", () => {
it("should create collection via UI dropdown menu", async () => {
const { container } = await render(<Excalidraw />);
// Open library sidebar
const libraryButton = container.querySelector(".sidebar-trigger");
fireEvent.click(libraryButton!);
// Wait for library menu to be visible
await waitFor(() => {
expect(container.querySelector(".layer-ui__library")).toBeTruthy();
});
// Click dropdown menu button in the header (not collection headers)
const libraryHeader = container.querySelector(
".library-menu-dropdown-container--in-heading",
) as HTMLElement;
const dropdownButton = queryByTestId(
libraryHeader,
"dropdown-menu-button",
);
fireEvent.click(dropdownButton!);
// Mock window.prompt to return a collection name
const originalPrompt = window.prompt;
window.prompt = vi.fn(() => "UI Test Collection");
// Click "Create library" option
const createButton = queryByTestId(container, "lib-dropdown--create");
fireEvent.click(createButton!);
// Restore prompt
window.prompt = originalPrompt;
// Wait for collection to be created
await waitFor(async () => {
const collections = await h.app.library.getCollections();
expect(collections.length).toBeGreaterThan(0);
expect(collections.some((c) => c.name === "UI Test Collection")).toBe(
true,
);
});
});
});
describe("collection and items relationship", () => {
it("should filter items by collection", async () => {
const collection1 = await h.app.library.createLibraryCollection(
"Collection 1",
);
const collection2 = await h.app.library.createLibraryCollection(
"Collection 2",
);
const items: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
collectionId: collection1.id,
},
{
id: "item2",
status: "unpublished",
elements: [API.createElement({ type: "ellipse" })],
created: Date.now(),
collectionId: collection1.id,
},
{
id: "item3",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
collectionId: collection2.id,
},
{
id: "item4",
status: "unpublished",
elements: [API.createElement({ type: "diamond" })],
created: Date.now(),
// No collectionId
},
];
await h.app.library.setLibrary(items);
const allItems = await h.app.library.getLatestLibrary();
const collection1Items = allItems.filter(
(item) => item.collectionId === collection1.id,
);
const collection2Items = allItems.filter(
(item) => item.collectionId === collection2.id,
);
const unassignedItems = allItems.filter((item) => !item.collectionId);
expect(collection1Items).toHaveLength(2);
expect(collection2Items).toHaveLength(1);
expect(unassignedItems).toHaveLength(1);
});
it("should handle items when collection is deleted", async () => {
const collection = await h.app.library.createLibraryCollection(
"Temp Collection",
);
const items: LibraryItems = [
{
id: "item1",
status: "unpublished",
elements: [API.createElement({ type: "rectangle" })],
created: Date.now(),
collectionId: collection.id,
},
{
id: "item2",
status: "unpublished",
elements: [API.createElement({ type: "ellipse" })],
created: Date.now(),
collectionId: collection.id,
},
];
await h.app.library.setLibrary(items);
// Delete collection
await h.app.library.deleteLibraryCollection(collection.id);
// Items should be deleted
const allItems = await h.app.library.getLatestLibrary();
expect(allItems).toHaveLength(0);
});
});
});

33
packages/excalidraw/types.ts

@ -516,9 +516,21 @@ export type LibraryItem = { @@ -516,9 +516,21 @@ export type LibraryItem = {
created: number;
name?: string;
error?: string;
collectionId?: string;
};
export type LibraryCollection = {
id: string;
name: string;
items: readonly LibraryItem[];
created: number;
color?: string; // TODO: implement color display and saving
};
export type LibraryCollections = readonly LibraryCollection[];
export type LibraryItems = readonly LibraryItem[];
export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1;
export type LibraryItems_anyVersion =
| LibraryItems
| LibraryItems_v1
| LibraryCollections;
export type LibraryItemsSource =
| ((
@ -531,6 +543,7 @@ export type ExcalidrawInitialDataState = Merge< @@ -531,6 +543,7 @@ export type ExcalidrawInitialDataState = Merge<
ImportedDataState,
{
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
libraryCollections?: MaybePromise<LibraryCollections>;
}
>;
@ -601,6 +614,9 @@ export interface ExcalidrawProps { @@ -601,6 +614,9 @@ export interface ExcalidrawProps {
detectScroll?: boolean;
handleKeyboardGlobally?: boolean;
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
onLibraryCollectionsChange?: (
collections: LibraryCollections,
) => void | Promise<any>;
autoFocus?: boolean;
generateIdForFile?: (file: File) => string | Promise<string>;
generateLinkForSelection?: (id: string, type: "element" | "group") => string;
@ -833,6 +849,21 @@ export interface ExcalidrawImperativeAPI { @@ -833,6 +849,21 @@ export interface ExcalidrawImperativeAPI {
applyDeltas: InstanceType<typeof App>["applyDeltas"];
mutateElement: InstanceType<typeof App>["mutateElement"];
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
createLibraryCollection: (
name: string,
color?: string,
) => Promise<LibraryCollection>;
deleteLibraryCollection: (collectionId: string) => Promise<void>;
renameLibraryCollection: (
collectionId: string,
newName: string,
) => Promise<void>;
moveUpCollection: (collectionId: string) => Promise<void>;
moveDownCollection: (collectionId: string) => Promise<void>;
getLibraryCollections: () => Promise<LibraryCollections>;
setLibraryCollection: (
collections: LibraryCollections,
) => Promise<LibraryCollections>;
resetScene: InstanceType<typeof App>["resetScene"];
getSceneElementsIncludingDeleted: InstanceType<
typeof App

Loading…
Cancel
Save