mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-18 05:29:54 +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 = {
|
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",
|
||||||
|
@@ -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,
|
||||||
|
@@ -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) => {
|
||||||
|
@@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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)
|
||||||
|
@@ -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,
|
||||||
|
@@ -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%;
|
||||||
}
|
}
|
||||||
|
@@ -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(() => {
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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;
|
||||||
|
@@ -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>
|
||||||
|
@@ -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;
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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;
|
||||||
|
@@ -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}>
|
||||||
|
@@ -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;
|
||||||
|
|
||||||
|
@@ -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
|
||||||
|
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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"
|
||||||
|
Reference in New Issue
Block a user