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:
David Luzar
2026-09-26 11:03:47 +02:00
committed by GitHub
parent 5db42c3ddb
commit 5feb2172d5
7 changed files with 253 additions and 22 deletions
@@ -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 {
+46 -4
View File
@@ -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,
})}
/>
);
+57 -13
View File
@@ -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}
>
+61 -1
View File
@@ -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 = (