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 = {
SIDEBAR: "sidebar",
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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%;
}
}
}
}

View File

@@ -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)

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

View File

@@ -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%;
}

View File

@@ -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(() => {

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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"