Pārlūkot izejas kodu

feat: library search (#9903)

* feat(utils): add support for search input type in isWritableElement

* feat(i18n): add search text

* feat(cmdp+lib): add search functionality for command pallete and lib menu items

* chore: fix formats, and whitespaces

* fix: opt to optimal code changes

* chore: fix for linting

* focus input on mount

* tweak placeholder

* design and UX changes

* tweak item hover/active/seletected states

* unrelated: move publish button above delete/clear to keep it more stable

* esc to clear search input / close sidebar

* refactor command pallete library stuff

* make library commands bigger

---------

Co-authored-by: dwelle <[email protected]>
Archie Sengupta 3 nedēļas atpakaļ
vecāks
revīzija
ec070911b8

+ 1 - 0
packages/common/src/constants.ts

@@ -125,6 +125,7 @@ export const ENV = {
 };
 
 export const CLASSES = {
+  SIDEBAR: "sidebar",
   SHAPE_ACTIONS_MENU: "App-menu__left",
   ZOOM_ACTIONS: "zoom-actions",
   SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",

+ 7 - 1
packages/common/src/utils.ts

@@ -93,7 +93,8 @@ export const isWritableElement = (
   (target instanceof HTMLInputElement &&
     (target.type === "text" ||
       target.type === "number" ||
-      target.type === "password"));
+      target.type === "password" ||
+      target.type === "search"));
 
 export const getFontFamilyString = ({
   fontFamily,
@@ -121,6 +122,11 @@ export const getFontString = ({
   return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
 };
 
+/** executes callback in the frame that's after the current one */
+export const nextAnimationFrame = async (cb: () => any) => {
+  requestAnimationFrame(() => requestAnimationFrame(cb));
+};
+
 export const debounce = <T extends any[]>(
   fn: (...args: T) => void,
   timeout: number,

+ 4 - 1
packages/excalidraw/components/ColorPicker/ColorPicker.tsx

@@ -6,6 +6,7 @@ import {
   COLOR_OUTLINE_CONTRAST_THRESHOLD,
   COLOR_PALETTE,
   isTransparent,
+  isWritableElement,
 } from "@excalidraw/common";
 
 import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
@@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({
       preventAutoFocusOnTouch={!!appState.editingTextElement}
       onFocusOutside={(event) => {
         // refocus due to eye dropper
-        focusPickerContent();
+        if (!isWritableElement(event.target)) {
+          focusPickerContent();
+        }
         event.preventDefault();
       }}
       onPointerDownOutside={(event) => {

+ 24 - 3
packages/excalidraw/components/CommandPalette/CommandPalette.scss

@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
         border-radius: var(--border-radius-lg);
         cursor: pointer;
 
+        --icon-size: 1rem;
+
+        &.command-item-large {
+          height: 2.75rem;
+          --icon-size: 1.5rem;
+
+          .icon {
+            width: var(--icon-size);
+            height: var(--icon-size);
+            margin-right: 0.625rem;
+          }
+        }
+
         &:active {
           background-color: var(--color-surface-low);
         }
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
     }
 
     .icon {
-      width: 16px;
-      height: 16px;
-      margin-right: 6px;
+      width: var(--icon-size, 1rem);
+      height: var(--icon-size, 1rem);
+      margin-right: 0.375rem;
+
+      .library-item-icon {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        width: 100%;
+      }
     }
   }
 }

+ 79 - 10
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -1,6 +1,6 @@
 import clsx from "clsx";
 import fuzzy from "fuzzy";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useMemo, useState } from "react";
 
 import {
   DEFAULT_SIDEBAR,
@@ -61,12 +61,21 @@ import { useStable } from "../../hooks/useStable";
 
 import { Ellipsify } from "../Ellipsify";
 
-import * as defaultItems from "./defaultCommandPaletteItems";
+import {
+  distributeLibraryItemsOnSquareGrid,
+  libraryItemsAtom,
+} from "../../data/library";
 
+import {
+  useLibraryCache,
+  useLibraryItemSvg,
+} from "../../hooks/useLibraryItemSvg";
+
+import * as defaultItems from "./defaultCommandPaletteItems";
 import "./CommandPalette.scss";
 
 import type { CommandPaletteItem } from "./types";
-import type { AppProps, AppState, UIAppState } from "../../types";
+import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
 import type { ShortcutName } from "../../actions/shortcuts";
 import type { TranslationKeys } from "../../i18n";
 import type { Action } from "../../actions/types";
@@ -80,6 +89,7 @@ export const DEFAULT_CATEGORIES = {
   editor: "Editor",
   elements: "Elements",
   links: "Links",
+  library: "Library",
 };
 
 const getCategoryOrder = (category: string) => {
@@ -207,6 +217,34 @@ function CommandPaletteInner({
     appProps,
   });
 
+  const [libraryItemsData] = useAtom(libraryItemsAtom);
+  const libraryCommands: CommandPaletteItem[] = useMemo(() => {
+    return (
+      libraryItemsData.libraryItems
+        ?.filter(
+          (libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
+            !!libraryItem.name,
+        )
+        .map((libraryItem) => ({
+          label: libraryItem.name,
+          icon: (
+            <LibraryItemIcon
+              id={libraryItem.id}
+              elements={libraryItem.elements}
+            />
+          ),
+          category: "Library",
+          order: getCategoryOrder("Library"),
+          haystack: deburr(libraryItem.name),
+          perform: () => {
+            app.onInsertElements(
+              distributeLibraryItemsOnSquareGrid([libraryItem]),
+            );
+          },
+        })) || []
+    );
+  }, [app, libraryItemsData.libraryItems]);
+
   useEffect(() => {
     // these props change often and we don't want them to re-run the effect
     // which would renew `allCommands`, cascading down and resetting state.
@@ -588,8 +626,9 @@ function CommandPaletteInner({
 
       setAllCommands(allCommands);
       setLastUsed(
-        allCommands.find((command) => command.label === lastUsed?.label) ??
-          null,
+        [...allCommands, ...libraryCommands].find(
+          (command) => command.label === lastUsed?.label,
+        ) ?? null,
       );
     }
   }, [
@@ -600,6 +639,7 @@ function CommandPaletteInner({
     lastUsed?.label,
     setLastUsed,
     setAppState,
+    libraryCommands,
   ]);
 
   const [commandSearch, setCommandSearch] = useState("");
@@ -796,9 +836,12 @@ function CommandPaletteInner({
       return nextCommandsByCategory;
     };
 
-    let matchingCommands = allCommands
-      .filter(isCommandAvailable)
-      .sort((a, b) => a.order - b.order);
+    let matchingCommands =
+      commandSearch?.length > 1
+        ? [...allCommands, ...libraryCommands]
+        : allCommands
+            .filter(isCommandAvailable)
+            .sort((a, b) => a.order - b.order);
 
     const showLastUsed =
       !commandSearch && lastUsed && isCommandAvailable(lastUsed);
@@ -822,14 +865,20 @@ function CommandPaletteInner({
     );
     matchingCommands = fuzzy
       .filter(_query, matchingCommands, {
-        extract: (command) => command.haystack,
+        extract: (command) => command.haystack ?? "",
       })
       .sort((a, b) => b.score - a.score)
       .map((item) => item.original);
 
     setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
     setCurrentCommand(matchingCommands[0] ?? null);
-  }, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
+  }, [
+    commandSearch,
+    allCommands,
+    isCommandAvailable,
+    lastUsed,
+    libraryCommands,
+  ]);
 
   return (
     <Dialog
@@ -904,6 +953,7 @@ function CommandPaletteInner({
                     onMouseMove={() => setCurrentCommand(command)}
                     showShortcut={!app.device.viewport.isMobile}
                     appState={uiAppState}
+                    size={category === "Library" ? "large" : "small"}
                   />
                 ))}
               </div>
@@ -919,6 +969,20 @@ function CommandPaletteInner({
     </Dialog>
   );
 }
+const LibraryItemIcon = ({
+  id,
+  elements,
+}: {
+  id: LibraryItem["id"] | null;
+  elements: LibraryItem["elements"] | undefined;
+}) => {
+  const ref = useRef<HTMLDivElement | null>(null);
+  const { svgCache } = useLibraryCache();
+
+  useLibraryItemSvg(id, elements, svgCache, ref);
+
+  return <div className="library-item-icon" ref={ref} />;
+};
 
 const CommandItem = ({
   command,
@@ -928,6 +992,7 @@ const CommandItem = ({
   onClick,
   showShortcut,
   appState,
+  size = "small",
 }: {
   command: CommandPaletteItem;
   isSelected: boolean;
@@ -936,6 +1001,7 @@ const CommandItem = ({
   onClick: (event: React.MouseEvent) => void;
   showShortcut: boolean;
   appState: UIAppState;
+  size?: "small" | "large";
 }) => {
   const noop = () => {};
 
@@ -944,6 +1010,7 @@ const CommandItem = ({
       className={clsx("command-item", {
         "item-selected": isSelected,
         "item-disabled": disabled,
+        "command-item-large": size === "large",
       })}
       ref={(ref) => {
         if (isSelected && !disabled) {
@@ -959,6 +1026,8 @@ const CommandItem = ({
       <div className="name">
         {command.icon && (
           <InlineIcon
+            className="icon"
+            size="var(--icon-size, 1rem)"
             icon={
               typeof command.icon === "function"
                 ? command.icon(appState)

+ 11 - 2
packages/excalidraw/components/InlineIcon.tsx

@@ -1,8 +1,17 @@
-export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
+export const InlineIcon = ({
+  className,
+  icon,
+  size = "1em",
+}: {
+  className?: string;
+  icon: React.ReactNode;
+  size?: string;
+}) => {
   return (
     <span
+      className={className}
       style={{
-        width: "1em",
+        width: size,
         margin: "0 0.5ex 0 0.5ex",
         display: "inline-block",
         lineHeight: 0,

+ 1 - 7
packages/excalidraw/components/LibraryMenu.scss

@@ -134,14 +134,8 @@
 
   .layer-ui__library .library-menu-dropdown-container {
     position: relative;
-
     &--in-heading {
-      padding: 0;
-      position: absolute;
-      top: 1rem;
-      right: 0.75rem;
-      z-index: 1;
-
+      margin-left: auto;
       .dropdown-menu {
         top: 100%;
       }

+ 37 - 1
packages/excalidraw/components/LibraryMenu.tsx

@@ -11,6 +11,11 @@ import {
   LIBRARY_DISABLED_TYPES,
   randomId,
   isShallowEqual,
+  KEYS,
+  isWritableElement,
+  addEventListener,
+  EVENT,
+  CLASSES,
 } from "@excalidraw/common";
 
 import type {
@@ -266,11 +271,42 @@ export const LibraryMenu = memo(() => {
   const memoizedLibrary = useMemo(() => app.library, [app.library]);
   const pendingElements = usePendingElementsMemo(appState, app);
 
+  useEffect(() => {
+    return addEventListener(
+      document,
+      EVENT.KEYDOWN,
+      (event) => {
+        if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) {
+          const target = event.target;
+          if (target.closest(`.${CLASSES.SIDEBAR}`)) {
+            // stop propagation so that we don't prevent it downstream
+            // (default browser behavior is to clear search input on ESC)
+            event.stopPropagation();
+            if (selectedItems.length > 0) {
+              setSelectedItems([]);
+            } else if (
+              isWritableElement(target) &&
+              target instanceof HTMLInputElement &&
+              !target.value
+            ) {
+              // if search input empty -> close library
+              // (maybe not a good idea?)
+              setAppState({ openSidebar: null });
+              app.focusContainer();
+            }
+          }
+        }
+      },
+      { capture: true },
+    );
+  }, [selectedItems, setAppState, app]);
+
   const onInsertLibraryItems = useCallback(
     (libraryItems: LibraryItems) => {
       onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
+      app.focusContainer();
     },
-    [onInsertElements],
+    [onInsertElements, app],
   );
 
   const deselectItems = useCallback(() => {

+ 8 - 8
packages/excalidraw/components/LibraryMenuHeaderContent.tsx

@@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{
               {t("buttons.export")}
             </DropdownMenu.Item>
           )}
-          {!!items.length && (
-            <DropdownMenu.Item
-              onSelect={() => setShowRemoveLibAlert(true)}
-              icon={TrashIcon}
-            >
-              {resetLabel}
-            </DropdownMenu.Item>
-          )}
           {itemsSelected && (
             <DropdownMenu.Item
               icon={publishIcon}
@@ -237,6 +229,14 @@ export const LibraryDropdownMenuButton: React.FC<{
               {t("buttons.publishLibrary")}
             </DropdownMenu.Item>
           )}
+          {!!items.length && (
+            <DropdownMenu.Item
+              onSelect={() => setShowRemoveLibAlert(true)}
+              icon={TrashIcon}
+            >
+              {resetLabel}
+            </DropdownMenu.Item>
+          )}
         </DropdownMenu.Content>
       </DropdownMenu>
     );

+ 61 - 6
packages/excalidraw/components/LibraryMenuItems.scss

@@ -1,24 +1,42 @@
 @import "open-color/open-color";
 
 .excalidraw {
-  --container-padding-y: 1.5rem;
+  --container-padding-y: 1rem;
   --container-padding-x: 0.75rem;
 
+  .library-menu-items-header {
+    display: flex;
+    padding-top: 1rem;
+    padding-bottom: 0.5rem;
+    gap: 0.5rem;
+  }
+
   .library-menu-items__no-items {
     text-align: center;
     color: var(--color-gray-70);
     line-height: 1.5;
     font-size: 0.875rem;
     width: 100%;
+    min-height: 55px;
+
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
 
     &__label {
       color: var(--color-primary);
       font-weight: 700;
       font-size: 1.125rem;
-      margin-bottom: 0.75rem;
+      margin-bottom: 0.25rem;
     }
   }
 
+  .library-menu-items__no-items__hint {
+    color: var(--color-border-outline);
+    padding: 0.75rem 1rem;
+  }
+
   &.theme--dark {
     .library-menu-items__no-items {
       color: var(--color-gray-40);
@@ -34,7 +52,7 @@
     overflow-y: auto;
     flex-direction: column;
     height: 100%;
-    justify-content: center;
+    justify-content: flex-start;
     margin: 0;
 
     position: relative;
@@ -51,26 +69,45 @@
     }
 
     &__items {
+      // so that spinner is relative-positioned to this container
+      position: relative;
+
       row-gap: 0.5rem;
-      padding: var(--container-padding-y) 0;
+      padding: 1rem 0 var(--container-padding-y) 0;
       flex: 1;
       overflow-y: auto;
       overflow-x: hidden;
-      margin-bottom: 1rem;
     }
 
     &__header {
+      display: flex;
+      align-items: center;
+      flex: 1 1 auto;
+
       color: var(--color-primary);
       font-size: 1.125rem;
       font-weight: 700;
       margin-bottom: 0.75rem;
       width: 100%;
-      padding-right: 4rem; // due to dropdown button
       box-sizing: border-box;
 
       &--excal {
         margin-top: 2rem;
       }
+
+      &__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 {
@@ -79,6 +116,24 @@
       grid-gap: 1rem;
     }
 
+    &__search {
+      flex: 1 1 auto;
+      margin: 0;
+
+      .ExcTextField__input {
+        height: var(--lg-button-size);
+        input {
+          font-size: 0.875rem;
+        }
+      }
+
+      &.hideCancelButton input::-webkit-search-cancel-button {
+        -webkit-appearance: none;
+        appearance: none;
+        display: none;
+      }
+    }
+
     .separator {
       width: 100%;
       display: flex;

+ 177 - 118
packages/excalidraw/components/LibraryMenuItems.tsx

@@ -6,10 +6,14 @@ import React, {
   useState,
 } from "react";
 
-import { MIME_TYPES, arrayToMap } from "@excalidraw/common";
+import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
 
 import { duplicateElements } from "@excalidraw/element";
 
+import clsx from "clsx";
+
+import { deburr } from "../deburr";
+
 import { useLibraryCache } from "../hooks/useLibraryItemSvg";
 import { useScrollPosition } from "../hooks/useScrollPosition";
 import { t } from "../i18n";
@@ -26,6 +30,10 @@ import Stack from "./Stack";
 
 import "./LibraryMenuItems.scss";
 
+import { TextField } from "./TextField";
+
+import { useDevice } from "./App";
+
 import type { ExcalidrawLibraryIds } from "../data/types";
 
 import type {
@@ -65,6 +73,7 @@ export default function LibraryMenuItems({
   selectedItems: LibraryItem["id"][];
   onSelectItems: (id: LibraryItem["id"][]) => void;
 }) {
+  const device = useDevice();
   const libraryContainerRef = useRef<HTMLDivElement>(null);
   const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
 
@@ -76,6 +85,30 @@ export default function LibraryMenuItems({
   }, []); // eslint-disable-line react-hooks/exhaustive-deps
 
   const { svgCache } = useLibraryCache();
+  const [lastSelectedItem, setLastSelectedItem] = useState<
+    LibraryItem["id"] | null
+  >(null);
+
+  const [searchInputValue, setSearchInputValue] = useState("");
+
+  const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
+
+  const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
+
+  const filteredItems = useMemo(() => {
+    const searchQuery = deburr(searchInputValue.trim().toLowerCase());
+    if (!searchQuery) {
+      return [];
+    }
+
+    return libraryItems.filter((item) => {
+      const itemName = item.name || "";
+      return (
+        itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery)
+      );
+    });
+  }, [libraryItems, searchInputValue]);
+
   const unpublishedItems = useMemo(
     () => libraryItems.filter((item) => item.status !== "published"),
     [libraryItems],
@@ -86,23 +119,10 @@ export default function LibraryMenuItems({
     [libraryItems],
   );
 
-  const showBtn = !libraryItems.length && !pendingElements.length;
-
-  const isLibraryEmpty =
-    !pendingElements.length &&
-    !unpublishedItems.length &&
-    !publishedItems.length;
-
-  const [lastSelectedItem, setLastSelectedItem] = useState<
-    LibraryItem["id"] | null
-  >(null);
-
   const onItemSelectToggle = useCallback(
     (id: LibraryItem["id"], event: React.MouseEvent) => {
       const shouldSelect = !selectedItems.includes(id);
-
       const orderedItems = [...unpublishedItems, ...publishedItems];
-
       if (shouldSelect) {
         if (event.shiftKey && lastSelectedItem) {
           const rangeStart = orderedItems.findIndex(
@@ -128,7 +148,6 @@ export default function LibraryMenuItems({
             },
             [],
           );
-
           onSelectItems(nextSelectedIds);
         } else {
           onSelectItems([...selectedItems, id]);
@@ -194,7 +213,6 @@ export default function LibraryMenuItems({
       if (!id) {
         return false;
       }
-
       return selectedItems.includes(id);
     },
     [selectedItems],
@@ -214,10 +232,120 @@ export default function LibraryMenuItems({
   );
 
   const itemsRenderedPerBatch =
-    svgCache.size >= libraryItems.length
+    svgCache.size >=
+    (filteredItems.length ? filteredItems : libraryItems).length
       ? CACHED_ITEMS_RENDERED_PER_BATCH
       : ITEMS_RENDERED_PER_BATCH;
 
+  const searchInputRef = useRef<HTMLInputElement>(null);
+  useEffect(() => {
+    // focus could be stolen by tab trigger button
+    nextAnimationFrame(() => {
+      searchInputRef.current?.focus();
+    });
+  }, []);
+
+  const JSX_whenNotSearching = !IS_SEARCHING && (
+    <>
+      {!IS_LIBRARY_EMPTY && (
+        <div className="library-menu-items-container__header">
+          {t("labels.personalLib")}
+        </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")}
+            </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 && (
+            <LibraryMenuSection
+              itemsRenderedPerBatch={itemsRenderedPerBatch}
+              items={[{ id: null, elements: pendingElements }]}
+              onItemSelectToggle={onItemSelectToggle}
+              onItemDrag={onItemDrag}
+              onClick={onAddToLibraryClick}
+              isItemSelected={isItemSelected}
+              svgCache={svgCache}
+            />
+          )}
+          <LibraryMenuSection
+            itemsRenderedPerBatch={itemsRenderedPerBatch}
+            items={unpublishedItems}
+            onItemSelectToggle={onItemSelectToggle}
+            onItemDrag={onItemDrag}
+            onClick={onItemClick}
+            isItemSelected={isItemSelected}
+            svgCache={svgCache}
+          />
+        </LibraryMenuSectionGrid>
+      )}
+
+      {publishedItems.length > 0 && (
+        <div
+          className="library-menu-items-container__header"
+          style={{ marginTop: "0.75rem" }}
+        >
+          {t("labels.excalidrawLib")}
+        </div>
+      )}
+      {publishedItems.length > 0 && (
+        <LibraryMenuSectionGrid>
+          <LibraryMenuSection
+            itemsRenderedPerBatch={itemsRenderedPerBatch}
+            items={publishedItems}
+            onItemSelectToggle={onItemSelectToggle}
+            onItemDrag={onItemDrag}
+            onClick={onItemClick}
+            isItemSelected={isItemSelected}
+            svgCache={svgCache}
+          />
+        </LibraryMenuSectionGrid>
+      )}
+    </>
+  );
+
+  const JSX_whenSearching = IS_SEARCHING && (
+    <>
+      <div className="library-menu-items-container__header">
+        {t("library.search.heading")}
+        {!isLoading && (
+          <div className="library-menu-items-container__header__hint">
+            <kbd>esc</kbd> to clear
+          </div>
+        )}
+      </div>
+      {filteredItems.length > 0 ? (
+        <LibraryMenuSectionGrid>
+          <LibraryMenuSection
+            itemsRenderedPerBatch={itemsRenderedPerBatch}
+            items={filteredItems}
+            onItemSelectToggle={onItemSelectToggle}
+            onItemDrag={onItemDrag}
+            onClick={onItemClick}
+            isItemSelected={isItemSelected}
+            svgCache={svgCache}
+          />
+        </LibraryMenuSectionGrid>
+      ) : (
+        <div className="library-menu-items__no-items">
+          <div className="library-menu-items__no-items__hint">
+            {t("library.search.noResults")}
+          </div>
+        </div>
+      )}
+    </>
+  );
+
   return (
     <div
       className="library-menu-items-container"
@@ -229,127 +357,58 @@ export default function LibraryMenuItems({
           : { borderBottom: 0 }
       }
     >
-      {!isLibraryEmpty && (
+      <div className="library-menu-items-header">
+        {!IS_LIBRARY_EMPTY && (
+          <TextField
+            ref={searchInputRef}
+            type="search"
+            className={clsx("library-menu-items-container__search", {
+              hideCancelButton: !device.editor.isMobile,
+            })}
+            placeholder={t("library.search.inputPlaceholder")}
+            value={searchInputValue}
+            onChange={(value) => setSearchInputValue(value)}
+          />
+        )}
         <LibraryDropdownMenu
           selectedItems={selectedItems}
           onSelectItems={onSelectItems}
           className="library-menu-dropdown-container--in-heading"
         />
-      )}
+      </div>
       <Stack.Col
         className="library-menu-items-container__items"
         align="start"
         gap={1}
         style={{
           flex: publishedItems.length > 0 ? 1 : "0 1 auto",
-          marginBottom: 0,
+          margin: IS_LIBRARY_EMPTY ? "auto" : 0,
         }}
         ref={libraryContainerRef}
       >
-        <>
-          {!isLibraryEmpty && (
-            <div className="library-menu-items-container__header">
-              {t("labels.personalLib")}
-            </div>
-          )}
-          {isLoading && (
-            <div
-              style={{
-                position: "absolute",
-                top: "var(--container-padding-y)",
-                right: "var(--container-padding-x)",
-                transform: "translateY(50%)",
-              }}
-            >
-              <Spinner />
-            </div>
-          )}
-          {!pendingElements.length && !unpublishedItems.length ? (
-            <div className="library-menu-items__no-items">
-              <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>
-          ) : (
-            <LibraryMenuSectionGrid>
-              {pendingElements.length > 0 && (
-                <LibraryMenuSection
-                  itemsRenderedPerBatch={itemsRenderedPerBatch}
-                  items={[{ id: null, elements: pendingElements }]}
-                  onItemSelectToggle={onItemSelectToggle}
-                  onItemDrag={onItemDrag}
-                  onClick={onAddToLibraryClick}
-                  isItemSelected={isItemSelected}
-                  svgCache={svgCache}
-                />
-              )}
-              <LibraryMenuSection
-                itemsRenderedPerBatch={itemsRenderedPerBatch}
-                items={unpublishedItems}
-                onItemSelectToggle={onItemSelectToggle}
-                onItemDrag={onItemDrag}
-                onClick={onItemClick}
-                isItemSelected={isItemSelected}
-                svgCache={svgCache}
-              />
-            </LibraryMenuSectionGrid>
-          )}
-        </>
-
-        <>
-          {(publishedItems.length > 0 ||
-            pendingElements.length > 0 ||
-            unpublishedItems.length > 0) && (
-            <div className="library-menu-items-container__header library-menu-items-container__header--excal">
-              {t("labels.excalidrawLib")}
-            </div>
-          )}
-          {publishedItems.length > 0 ? (
-            <LibraryMenuSectionGrid>
-              <LibraryMenuSection
-                itemsRenderedPerBatch={itemsRenderedPerBatch}
-                items={publishedItems}
-                onItemSelectToggle={onItemSelectToggle}
-                onItemDrag={onItemDrag}
-                onClick={onItemClick}
-                isItemSelected={isItemSelected}
-                svgCache={svgCache}
-              />
-            </LibraryMenuSectionGrid>
-          ) : unpublishedItems.length > 0 ? (
-            <div
-              style={{
-                margin: "1rem 0",
-                display: "flex",
-                flexDirection: "column",
-                alignItems: "center",
-                justifyContent: "center",
-                width: "100%",
-                fontSize: ".9rem",
-              }}
-            >
-              {t("library.noItems")}
-            </div>
-          ) : null}
-        </>
+        {isLoading && (
+          <div
+            style={{
+              position: "absolute",
+              top: "var(--container-padding-y)",
+              right: "var(--container-padding-x)",
+              transform: "translateY(50%)",
+            }}
+          >
+            <Spinner />
+          </div>
+        )}
 
-        {showBtn && (
+        {JSX_whenNotSearching}
+        {JSX_whenSearching}
+
+        {IS_LIBRARY_EMPTY && (
           <LibraryMenuControlButtons
             style={{ padding: "16px 0", width: "100%" }}
             id={id}
             libraryReturnUrl={libraryReturnUrl}
             theme={theme}
-          >
-            <LibraryDropdownMenu
-              selectedItems={selectedItems}
-              onSelectItems={onSelectItems}
-            />
-          </LibraryMenuControlButtons>
+          />
         )}
       </Stack.Col>
     </div>

+ 1 - 1
packages/excalidraw/components/LibraryMenuSection.tsx

@@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
 import type { LibraryItem } from "../types";
 import type { ReactNode } from "react";
 
-type LibraryOrPendingItem = (
+type LibraryOrPendingItem = readonly (
   | LibraryItem
   | /* pending library item */ {
       id: null;

+ 3 - 3
packages/excalidraw/components/LibraryUnit.scss

@@ -18,12 +18,12 @@
     }
 
     &--hover {
-      border-color: var(--color-primary);
+      background-color: var(--color-surface-mid);
     }
 
+    &:active:not(:has(.library-unit__checkbox:hover)),
     &--selected {
-      border-color: var(--color-primary);
-      border-width: 1px;
+      background-color: var(--color-surface-high);
     }
 
     &--skeleton {

+ 2 - 18
packages/excalidraw/components/LibraryUnit.tsx

@@ -1,5 +1,5 @@
 import clsx from "clsx";
-import { memo, useEffect, useRef, useState } from "react";
+import { memo, useRef, useState } from "react";
 
 import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
 
@@ -33,23 +33,7 @@ export const LibraryUnit = memo(
     svgCache: SvgCache;
   }) => {
     const ref = useRef<HTMLDivElement | null>(null);
-    const svg = useLibraryItemSvg(id, elements, svgCache);
-
-    useEffect(() => {
-      const node = ref.current;
-
-      if (!node) {
-        return;
-      }
-
-      if (svg) {
-        node.innerHTML = svg.outerHTML;
-      }
-
-      return () => {
-        node.innerHTML = "";
-      };
-    }, [svg]);
+    const svg = useLibraryItemSvg(id, elements, svgCache, ref);
 
     const [isHovered, setIsHovered] = useState(false);
     const isMobile = useDevice().editor.isMobile;

+ 12 - 2
packages/excalidraw/components/Sidebar/Sidebar.tsx

@@ -9,7 +9,13 @@ import React, {
   useCallback,
 } from "react";
 
-import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common";
+import {
+  CLASSES,
+  EVENT,
+  isDevEnv,
+  KEYS,
+  updateObject,
+} from "@excalidraw/common";
 
 import { useUIAppState } from "../../context/ui-appState";
 import { atom, useSetAtom } from "../../editor-jotai";
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
     return (
       <Island
         {...rest}
-        className={clsx("sidebar", { "sidebar--docked": docked }, className)}
+        className={clsx(
+          CLASSES.SIDEBAR,
+          { "sidebar--docked": docked },
+          className,
+        )}
         ref={islandRef}
       >
         <SidebarPropsContext.Provider value={headerPropsRef.current}>

+ 4 - 0
packages/excalidraw/components/TextField.scss

@@ -12,6 +12,10 @@
   --ExcTextField--border-active: var(--color-brand-active);
   --ExcTextField--placeholder: var(--color-border-outline-variant);
 
+  &.theme--dark {
+    --ExcTextField--border: var(--color-border-outline-variant);
+  }
+
   .ExcTextField {
     position: relative;
 

+ 3 - 0
packages/excalidraw/components/TextField.tsx

@@ -28,6 +28,7 @@ type TextFieldProps = {
   className?: string;
   placeholder?: string;
   isRedacted?: boolean;
+  type?: "text" | "search";
 } & ({ value: string } | { defaultValue: string });
 
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@@ -43,6 +44,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       isRedacted = false,
       icon,
       className,
+      type,
       ...rest
     },
     ref,
@@ -96,6 +98,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
             ref={innerRef}
             onChange={(event) => onChange?.(event.target.value)}
             onKeyDown={onKeyDown}
+            type={type}
           />
           {isRedacted && (
             <Button

+ 17 - 0
packages/excalidraw/hooks/useLibraryItemSvg.ts

@@ -28,6 +28,7 @@ export const useLibraryItemSvg = (
   id: LibraryItem["id"] | null,
   elements: LibraryItem["elements"] | undefined,
   svgCache: SvgCache,
+  ref: React.RefObject<HTMLDivElement | null>,
 ): SVGSVGElement | undefined => {
   const [svg, setSvg] = useState<SVGSVGElement>();
 
@@ -62,6 +63,22 @@ export const useLibraryItemSvg = (
     }
   }, [id, elements, svgCache, setSvg]);
 
+  useEffect(() => {
+    const node = ref.current;
+
+    if (!node) {
+      return;
+    }
+
+    if (svg) {
+      node.innerHTML = svg.outerHTML;
+    }
+
+    return () => {
+      node.innerHTML = "";
+    };
+  }, [svg, ref]);
+
   return svg;
 };
 

+ 7 - 2
packages/excalidraw/locales/en.json

@@ -181,7 +181,12 @@
   "library": {
     "noItems": "No items added yet...",
     "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
-    "hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
+    "hint_emptyPrivateLibrary": "Select an item on canvas to add it here.",
+    "search": {
+      "inputPlaceholder": "Search library",
+      "heading": "Library matches",
+      "noResults": "No matching items found..."
+    }
   },
   "search": {
     "title": "Find on canvas",
@@ -227,7 +232,7 @@
     "clear": "Clear",
     "remove": "Remove",
     "embed": "Toggle embedding",
-    "publishLibrary": "Publish",
+    "publishLibrary": "Publish selected",
     "submit": "Submit",
     "confirm": "Confirm",
     "embeddableInteractionButton": "Click to interact"