mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
feat(editor): better library search + display names (#12173)
* feat(editor): delayed name tooltip on library items - library items with a name show it in a tooltip above the item after the standard tooltip delay (500ms, or right away when moving over from another tooltip); hidden on leave and on press - driven by pointer (not mouse) events so touch taps don't leave the tooltip behind, while the mouse-driven hover state (which reveals the checkbox on tablets) stays as is - no per-item hooks: Tooltip exposes `showTooltip()` / `hideTooltip()` (plus a `position` option) for elements that can't be wrapped in <Tooltip>, and <Tooltip> itself now uses them - a visible tooltip now hides once its item is removed from the DOM (e.g. filtered out by the library search, sidebar closed), which fires no pointerleave. A MutationObserver is active only while a tooltip is visible; a delayed tooltip whose item unmounted while pending no longer shows Opus 5.5 (1M context), xhigh * fix(editor): Escape in library search clears it instead of closing sidebar With a search active in an undocked sidebar, Escape closed the sidebar: the search relied on the browser's native clearing of search inputs, but nothing stopped the event from reaching the Sidebar's document Escape listener. Clear the search ourselves and stop the event on the input. A second Escape (empty search) still closes the sidebar. Also adds a test for Escape clearing the item selection without closing the sidebar (already working). Opus 5.5 (1M context), xhigh * feat(editor): larger named items in library search results - search results render in 2 columns with bigger previews and the item name below (ellipsified when it overflows); the whole card, name included, is clickable/draggable - the name tooltip only shows when the name is truncated - placeholders match the card size, and the grid is full width so it doesn't shrink to its content while only placeholders are rendered - reserve the library scrollbar's space (scrollbar-gutter: stable) so the content doesn't shift when results start/stop overflowing Opus 5.5 (1M context), xhigh
This commit is contained in:
@@ -77,6 +77,9 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
// reserve the (thin) scrollbar's space so the content doesn't shift
|
||||
// when it starts/stops overflowing (e.g. while searching)
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
&__header {
|
||||
@@ -114,6 +117,15 @@
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-gap: 1rem;
|
||||
|
||||
&--named {
|
||||
// items are sized by the grid (not fixed like the default ones), so
|
||||
// don't let it shrink to its content (e.g. only placeholders yet)
|
||||
width: 100%;
|
||||
// minmax so long (nowrap) names don't widen the columns
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__search {
|
||||
|
||||
@@ -6,7 +6,12 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
|
||||
import {
|
||||
KEYS,
|
||||
MIME_TYPES,
|
||||
arrayToMap,
|
||||
nextAnimationFrame,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element";
|
||||
|
||||
@@ -346,7 +351,7 @@ export default function LibraryMenuItems({
|
||||
)}
|
||||
</div>
|
||||
{filteredItems.length > 0 ? (
|
||||
<LibraryMenuSectionGrid>
|
||||
<LibraryMenuSectionGrid showNames>
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={filteredItems}
|
||||
@@ -355,6 +360,7 @@ export default function LibraryMenuItems({
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
showNames
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
) : (
|
||||
@@ -398,6 +404,15 @@ export default function LibraryMenuItems({
|
||||
placeholder={t("library.search.inputPlaceholder")}
|
||||
value={searchInputValue}
|
||||
onChange={(value) => setSearchInputValue(value)}
|
||||
onKeyDown={(event) => {
|
||||
// clear the search ourselves and stop the event, or the
|
||||
// sidebar's Escape listener closes an undocked sidebar
|
||||
if (event.key === KEYS.ESCAPE && searchInputValue) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSearchInputValue("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LibraryDropdownMenu
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import React, { memo, useEffect, useState } from "react";
|
||||
|
||||
import type { ExcalidrawElement, NonDeleted } from "@excalidraw/element/types";
|
||||
@@ -26,14 +27,27 @@ interface Props {
|
||||
isItemSelected: (id: LibraryItem["id"] | null) => boolean;
|
||||
svgCache: SvgCache;
|
||||
itemsRenderedPerBatch: number;
|
||||
/** larger items with their name below */
|
||||
showNames?: boolean;
|
||||
}
|
||||
|
||||
export const LibraryMenuSectionGrid = ({
|
||||
children,
|
||||
showNames,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** fewer columns, for items rendered with `showNames` */
|
||||
showNames?: boolean;
|
||||
}) => {
|
||||
return <div className="library-menu-items-container__grid">{children}</div>;
|
||||
return (
|
||||
<div
|
||||
className={clsx("library-menu-items-container__grid", {
|
||||
"library-menu-items-container__grid--named": showNames,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LibraryMenuSection = memo(
|
||||
@@ -45,6 +59,7 @@ export const LibraryMenuSection = memo(
|
||||
onClick,
|
||||
svgCache,
|
||||
itemsRenderedPerBatch,
|
||||
showNames,
|
||||
}: Props) => {
|
||||
const [, startTransition] = useTransition();
|
||||
const [index, setIndex] = useState(0);
|
||||
@@ -67,13 +82,15 @@ export const LibraryMenuSection = memo(
|
||||
onClick={onClick}
|
||||
svgCache={svgCache}
|
||||
id={item?.id}
|
||||
name={"name" in item ? item.name : undefined}
|
||||
showName={showNames}
|
||||
selected={isItemSelected(item.id)}
|
||||
onToggle={onItemSelectToggle}
|
||||
onDrag={onItemDrag}
|
||||
key={item?.id ?? i}
|
||||
/>
|
||||
) : (
|
||||
<EmptyLibraryUnit key={i} />
|
||||
<EmptyLibraryUnit key={i} showName={showNames} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -63,6 +63,47 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.library-unit--named {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
|
||||
.library-unit__dragger {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.library-unit__preview {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> svg {
|
||||
filter: var(--theme-filter);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.library-unit__name {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
height: 1rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.library-unit__checkbox-container,
|
||||
.library-unit__checkbox-container:hover,
|
||||
.library-unit__checkbox-container:active {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
||||
import { useEditorInterface } from "./App";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { PlusIcon } from "./icons";
|
||||
import { hideTooltip, showTooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryUnit.scss";
|
||||
|
||||
@@ -15,6 +16,8 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
export const LibraryUnit = memo(
|
||||
({
|
||||
id,
|
||||
name,
|
||||
showName,
|
||||
elements,
|
||||
isPending,
|
||||
onClick,
|
||||
@@ -24,6 +27,9 @@ export const LibraryUnit = memo(
|
||||
svgCache,
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
name?: LibraryItem["name"];
|
||||
/** render larger, with the name below the item */
|
||||
showName?: boolean;
|
||||
elements?: LibraryItem["elements"];
|
||||
isPending?: boolean;
|
||||
onClick: (id: LibraryItem["id"] | null) => void;
|
||||
@@ -37,6 +43,8 @@ export const LibraryUnit = memo(
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useEditorInterface().formFactor === "phone";
|
||||
const hasName = !!name?.trim();
|
||||
|
||||
const adder = isPending && (
|
||||
<div className="library-unit__adder">{PlusIcon}</div>
|
||||
);
|
||||
@@ -48,15 +56,38 @@ export const LibraryUnit = memo(
|
||||
"library-unit--hover": elements && isHovered,
|
||||
"library-unit--selected": selected,
|
||||
"library-unit--skeleton": !svg,
|
||||
"library-unit--named": showName,
|
||||
})}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
// pointer (not mouse) events so that touch taps don't leave the
|
||||
// tooltip behind (pointerdown right after pointerenter cancels it)
|
||||
onPointerEnter={
|
||||
hasName
|
||||
? (event) => {
|
||||
const nameEl = showName
|
||||
? event.currentTarget.querySelector(".library-unit__name")
|
||||
: null;
|
||||
// name is already shown below the item, unless truncated
|
||||
if (nameEl && nameEl.scrollWidth <= nameEl.clientWidth) {
|
||||
return;
|
||||
}
|
||||
showTooltip(event.currentTarget, name!, {
|
||||
delay: true,
|
||||
position: "top",
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerLeave={hasName ? hideTooltip : undefined}
|
||||
onPointerDown={hasName ? hideTooltip : undefined}
|
||||
>
|
||||
<div
|
||||
className={clsx("library-unit__dragger", {
|
||||
"library-unit__pulse": !!isPending,
|
||||
})}
|
||||
ref={ref}
|
||||
// svg is rendered into `ref` (replacing its contents)
|
||||
ref={showName ? undefined : ref}
|
||||
draggable={!!elements}
|
||||
onClick={
|
||||
!!elements || !!isPending
|
||||
@@ -77,7 +108,14 @@ export const LibraryUnit = memo(
|
||||
setIsHovered(false);
|
||||
onDrag(id, event);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{showName && (
|
||||
<>
|
||||
<div className="library-unit__preview" ref={ref} />
|
||||
<div className="library-unit__name">{name}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{adder}
|
||||
{id && elements && (isHovered || isMobile || selected) && (
|
||||
<CheckboxItem
|
||||
@@ -91,6 +129,10 @@ export const LibraryUnit = memo(
|
||||
},
|
||||
);
|
||||
|
||||
export const EmptyLibraryUnit = () => (
|
||||
<div className="library-unit library-unit--skeleton" />
|
||||
export const EmptyLibraryUnit = ({ showName }: { showName?: boolean }) => (
|
||||
<div
|
||||
className={clsx("library-unit library-unit--skeleton", {
|
||||
"library-unit--named": showName,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -70,9 +70,16 @@ const TOOLTIP_WARM_WINDOW = 300;
|
||||
|
||||
let showTooltipTimer = 0;
|
||||
let tooltipHiddenAt = 0;
|
||||
/**
|
||||
* while a tooltip is visible, hides it once its item is removed from the DOM
|
||||
* (e.g. unmounted while hovered, which doesn't fire pointerleave)
|
||||
*/
|
||||
let tooltipItemObserver: MutationObserver | null = null;
|
||||
|
||||
const hideTooltip = () => {
|
||||
export const hideTooltip = () => {
|
||||
clearTimeout(showTooltipTimer);
|
||||
tooltipItemObserver?.disconnect();
|
||||
tooltipItemObserver = null;
|
||||
const tooltip = getTooltipDiv();
|
||||
if (tooltip.classList.contains("excalidraw-tooltip--visible")) {
|
||||
tooltip.classList.remove("excalidraw-tooltip--visible");
|
||||
@@ -81,10 +88,11 @@ const hideTooltip = () => {
|
||||
};
|
||||
|
||||
const updateTooltip = (
|
||||
item: HTMLDivElement,
|
||||
item: HTMLElement,
|
||||
tooltip: HTMLDivElement,
|
||||
label: string,
|
||||
long: boolean,
|
||||
position: "bottom" | "top",
|
||||
) => {
|
||||
tooltip.classList.add("excalidraw-tooltip--visible");
|
||||
tooltip.style.minWidth = long ? "50ch" : "10ch";
|
||||
@@ -93,7 +101,50 @@ const updateTooltip = (
|
||||
tooltip.textContent = label;
|
||||
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
updateTooltipPosition(tooltip, itemRect);
|
||||
updateTooltipPosition(tooltip, itemRect, position);
|
||||
|
||||
tooltipItemObserver?.disconnect();
|
||||
tooltipItemObserver = new MutationObserver(() => {
|
||||
if (!item.isConnected) {
|
||||
hideTooltip();
|
||||
}
|
||||
});
|
||||
tooltipItemObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the tooltip for `item`. For elements that can't be wrapped
|
||||
* in <Tooltip>. Pair with `hideTooltip()`.
|
||||
*/
|
||||
export const showTooltip = (
|
||||
item: HTMLElement,
|
||||
label: string,
|
||||
{
|
||||
long = false,
|
||||
delay = false,
|
||||
position = "bottom",
|
||||
}: {
|
||||
long?: boolean;
|
||||
/** show after a short delay (unless a tooltip was visible just now) */
|
||||
delay?: boolean;
|
||||
position?: "bottom" | "top";
|
||||
} = {},
|
||||
) => {
|
||||
const show = () => {
|
||||
// item may have been unmounted while the delayed tooltip was pending
|
||||
if (item.isConnected) {
|
||||
updateTooltip(item, getTooltipDiv(), label, long, position);
|
||||
}
|
||||
};
|
||||
clearTimeout(showTooltipTimer);
|
||||
if (delay && Date.now() - tooltipHiddenAt > TOOLTIP_WARM_WINDOW) {
|
||||
showTooltipTimer = window.setTimeout(show, TOOLTIP_DELAY);
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
};
|
||||
|
||||
type TooltipProps = {
|
||||
@@ -125,16 +176,9 @@ export const Tooltip = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx("excalidraw-tooltip-wrapper", className)}
|
||||
onPointerEnter={(event) => {
|
||||
const item = event.currentTarget as HTMLDivElement;
|
||||
const show = () => updateTooltip(item, getTooltipDiv(), label, long);
|
||||
clearTimeout(showTooltipTimer);
|
||||
if (delay && Date.now() - tooltipHiddenAt > TOOLTIP_WARM_WINDOW) {
|
||||
showTooltipTimer = window.setTimeout(show, TOOLTIP_DELAY);
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
}}
|
||||
onPointerEnter={(event) =>
|
||||
showTooltip(event.currentTarget, label, { long, delay })
|
||||
}
|
||||
onPointerLeave={hideTooltip}
|
||||
style={style}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { act, queryByTestId } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
||||
import { KEYS, MIME_TYPES, ORIG_ID } from "@excalidraw/common";
|
||||
|
||||
import { getCommonBoundingBox } from "@excalidraw/element";
|
||||
|
||||
@@ -274,6 +274,66 @@ describe("library menu", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("library menu Escape (undocked sidebar)", () => {
|
||||
const openLibraryWithItems = async () => {
|
||||
const { container } = await render(<Excalidraw />);
|
||||
await act(() =>
|
||||
h.app.library.updateLibrary({
|
||||
libraryItems: ["wall", "door"].map((name) => ({
|
||||
id: name,
|
||||
name,
|
||||
status: "unpublished",
|
||||
created: 1,
|
||||
elements: [API.createElement({ type: "rectangle", id: name })],
|
||||
})),
|
||||
openLibraryMenu: true,
|
||||
}),
|
||||
);
|
||||
const searchInput = await waitFor(() => {
|
||||
const input = container.querySelector<HTMLInputElement>(
|
||||
".library-menu-items-container__search input",
|
||||
);
|
||||
expect(input).not.toBeNull();
|
||||
return input!;
|
||||
});
|
||||
expect(h.state.openSidebar).not.toBe(null);
|
||||
return { container, searchInput };
|
||||
};
|
||||
|
||||
it("should clear an active search instead of closing the sidebar", async () => {
|
||||
const { searchInput } = await openLibraryWithItems();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "wall" } });
|
||||
expect(searchInput.value).toBe("wall");
|
||||
|
||||
fireEvent.keyDown(searchInput, { key: KEYS.ESCAPE });
|
||||
expect(h.state.openSidebar).not.toBe(null);
|
||||
expect(searchInput.value).toBe("");
|
||||
|
||||
// with the search cleared, Escape closes the sidebar
|
||||
fireEvent.keyDown(searchInput, { key: KEYS.ESCAPE });
|
||||
expect(h.state.openSidebar).toBe(null);
|
||||
});
|
||||
|
||||
it("should clear the item selection instead of closing the sidebar", async () => {
|
||||
const { container, searchInput } = await openLibraryWithItems();
|
||||
|
||||
const itemDragger = await waitFor(() => {
|
||||
const dragger = container.querySelector(".library-unit__dragger");
|
||||
expect(dragger).not.toBeNull();
|
||||
return dragger!;
|
||||
});
|
||||
fireEvent.click(itemDragger, { shiftKey: true });
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".library-unit--selected")).not.toBe(null),
|
||||
);
|
||||
|
||||
fireEvent.keyDown(searchInput, { key: KEYS.ESCAPE });
|
||||
expect(container.querySelector(".library-unit--selected")).toBe(null);
|
||||
expect(h.state.openSidebar).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeLibraryItemsOnSquareGrid()", () => {
|
||||
it("should distribute items on a grid", async () => {
|
||||
const createLibraryItem = (
|
||||
|
||||
Reference in New Issue
Block a user