mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-05 07:09:53 +02: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:
@@ -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",
|
||||
|
@@ -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,
|
||||
|
@@ -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