mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	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 <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
		
				
					committed by
					
						
						Mark Tolmacs
					
				
			
			
				
	
			
			
			
						parent
						
							1ce81e7347
						
					
				
				
					commit
					dbb0a39b22
				
			@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -89,7 +89,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,
 | 
			
		||||
@@ -117,6 +118,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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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) => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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, [])
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -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%;
 | 
			
		||||
      }
 | 
			
		||||
 
 | 
			
		||||
@@ -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(() => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
          )}
 | 
			
		||||
        </>
 | 
			
		||||
        {isLoading && (
 | 
			
		||||
          <div
 | 
			
		||||
            style={{
 | 
			
		||||
              position: "absolute",
 | 
			
		||||
              top: "var(--container-padding-y)",
 | 
			
		||||
              right: "var(--container-padding-x)",
 | 
			
		||||
              transform: "translateY(50%)",
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Spinner />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <>
 | 
			
		||||
          {(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}
 | 
			
		||||
        </>
 | 
			
		||||
        {JSX_whenNotSearching}
 | 
			
		||||
        {JSX_whenSearching}
 | 
			
		||||
 | 
			
		||||
        {showBtn && (
 | 
			
		||||
        {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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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}>
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user