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:
Archie Sengupta
2025-09-28 13:16:28 -07:00
committed by GitHub
parent dcdeb2be57
commit ec070911b8
19 changed files with 458 additions and 182 deletions

View File

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

View File

@@ -93,7 +93,8 @@ export const isWritableElement = (
(target instanceof HTMLInputElement && (target instanceof HTMLInputElement &&
(target.type === "text" || (target.type === "text" ||
target.type === "number" || target.type === "number" ||
target.type === "password")); target.type === "password" ||
target.type === "search"));
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@@ -121,6 +122,11 @@ export const getFontString = ({
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; 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[]>( export const debounce = <T extends any[]>(
fn: (...args: T) => void, fn: (...args: T) => void,
timeout: number, timeout: number,

View File

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

View File

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

View File

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

View File

@@ -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 ( return (
<span <span
className={className}
style={{ style={{
width: "1em", width: size,
margin: "0 0.5ex 0 0.5ex", margin: "0 0.5ex 0 0.5ex",
display: "inline-block", display: "inline-block",
lineHeight: 0, lineHeight: 0,

View File

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

View File

@@ -11,6 +11,11 @@ import {
LIBRARY_DISABLED_TYPES, LIBRARY_DISABLED_TYPES,
randomId, randomId,
isShallowEqual, isShallowEqual,
KEYS,
isWritableElement,
addEventListener,
EVENT,
CLASSES,
} from "@excalidraw/common"; } from "@excalidraw/common";
import type { import type {
@@ -266,11 +271,42 @@ export const LibraryMenu = memo(() => {
const memoizedLibrary = useMemo(() => app.library, [app.library]); const memoizedLibrary = useMemo(() => app.library, [app.library]);
const pendingElements = usePendingElementsMemo(appState, app); 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( const onInsertLibraryItems = useCallback(
(libraryItems: LibraryItems) => { (libraryItems: LibraryItems) => {
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
app.focusContainer();
}, },
[onInsertElements], [onInsertElements, app],
); );
const deselectItems = useCallback(() => { const deselectItems = useCallback(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,12 @@
"library": { "library": {
"noItems": "No items added yet...", "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_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": { "search": {
"title": "Find on canvas", "title": "Find on canvas",
@@ -227,7 +232,7 @@
"clear": "Clear", "clear": "Clear",
"remove": "Remove", "remove": "Remove",
"embed": "Toggle embedding", "embed": "Toggle embedding",
"publishLibrary": "Publish", "publishLibrary": "Publish selected",
"submit": "Submit", "submit": "Submit",
"confirm": "Confirm", "confirm": "Confirm",
"embeddableInteractionButton": "Click to interact" "embeddableInteractionButton": "Click to interact"