feat: include frame names in canvas searches (#9484)

* fix frame name clipping on zooming

* include assistant font

* default frame name

* extend search to frame names

* add a simple test

* collpase search match items

* id check out of loop

* fix frame name check

* include focusedId for small perf improvement

* optionally show and hide collapse icon

* update section title

* fix tests

* rename `serverSide` -> `private`

* revert: do not reset zoom on zoom change

* feat: do not close menu on repeated ctrl+f

* remove collapsible

* tweak results CSS

* remove redundant check

* set `appState.searchMatches` to null if empty

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di
2025-05-09 18:32:16 +02:00
committed by GitHub
co-authored by dwelle
parent ff2ed5d26a
commit a30e1b25c6
19 changed files with 502 additions and 241 deletions
+28 -9
View File
@@ -1417,8 +1417,19 @@ class App extends React.Component<AppProps, AppState> {
}
const isDarkTheme = this.state.theme === THEME.DARK;
const nonDeletedFramesLikes = this.scene.getNonDeletedFramesLikes();
return this.scene.getNonDeletedFramesLikes().map((f) => {
const focusedSearchMatch =
nonDeletedFramesLikes.length > 0
? this.state.searchMatches?.focusedId &&
isFrameLikeElement(
this.scene.getElement(this.state.searchMatches.focusedId),
)
? this.state.searchMatches.matches.find((sm) => sm.focus)
: null
: null;
return nonDeletedFramesLikes.map((f) => {
if (
!isElementInViewport(
f,
@@ -1484,7 +1495,7 @@ class App extends React.Component<AppProps, AppState> {
borderRadius: 4,
boxShadow: "inset 0 0 0 1px var(--color-primary)",
fontFamily: "Assistant",
fontSize: "14px",
fontSize: `${FRAME_STYLE.nameFontSize}px`,
transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
color: "var(--color-gray-80)",
overflow: "hidden",
@@ -1528,7 +1539,10 @@ class App extends React.Component<AppProps, AppState> {
: FRAME_STYLE.nameColorLightTheme,
lineHeight: FRAME_STYLE.nameLineHeight,
width: "max-content",
maxWidth: `${f.width}px`,
maxWidth:
focusedSearchMatch?.id === f.id && focusedSearchMatch?.focus
? "none"
: `${f.width * this.state.zoom.value}px`,
overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
@@ -6405,12 +6419,17 @@ class App extends React.Component<AppProps, AppState> {
this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
this.setState((state) => ({
searchMatches: state.searchMatches.map((searchMatch) => ({
...searchMatch,
focus: false,
})),
}));
this.setState((state) => {
return {
searchMatches: state.searchMatches && {
focusedId: null,
matches: state.searchMatches.matches.map((searchMatch) => ({
...searchMatch,
focus: false,
})),
},
};
});
this.updateEditorAtom(searchItemInFocusAtom, null);
}
@@ -99,7 +99,7 @@ export const FontPickerList = React.memo(
() =>
Array.from(Fonts.registered.entries())
.filter(
([_, { metadata }]) => !metadata.serverSide && !metadata.fallback,
([_, { metadata }]) => !metadata.private && !metadata.fallback,
)
.map(([familyId, { metadata, fontFaces }]) => {
const fontDescriptor = {
@@ -39,7 +39,7 @@ const getHints = ({
if (
appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
appState.searchMatches?.length
appState.searchMatches?.matches.length
) {
return t("hints.dismissSearch");
}
+33 -2
View File
@@ -1,4 +1,5 @@
@import "open-color/open-color";
@import "../css//variables.module.scss";
.excalidraw {
.layer-ui__search {
@@ -64,21 +65,51 @@
flex: 1 1 0;
display: flex;
flex-direction: column;
padding: 0 0.75rem;
gap: 0.125rem;
}
.layer-ui__search .collapsible-items {
gap: 2px;
}
.layer-ui__search-result-title {
font-size: 0.875rem;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: 700;
.title-icon {
width: 0.875rem;
height: 0.875rem;
margin-right: 0.25rem;
svg g {
stroke-width: 1.25;
}
}
}
.layer-ui__divider {
width: 100%;
margin-top: 0.25rem;
margin-bottom: 1rem;
position: relative;
}
.layer-ui__result-item {
display: flex;
align-items: center;
min-height: 2rem;
min-height: 1.875rem;
flex: 0 0 auto;
padding: 0.25rem 0.75rem;
cursor: pointer;
border: 1px solid transparent;
outline: none;
font-size: 16px;
margin: 0 0.75rem;
border-radius: var(--border-radius-md);
.text-icon {
+208 -60
View File
@@ -1,9 +1,15 @@
import { round } from "@excalidraw/math";
import clsx from "clsx";
import debounce from "lodash.debounce";
import { Fragment, memo, useEffect, useRef, useState } from "react";
import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react";
import { CLASSES, EVENT } from "@excalidraw/common";
import {
CLASSES,
EVENT,
FONT_FAMILY,
FRAME_STYLE,
getLineHeight,
} from "@excalidraw/common";
import { isElementCompletelyInViewport } from "@excalidraw/element/sizeHelpers";
@@ -17,9 +23,17 @@ import {
} from "@excalidraw/common";
import { newTextElement } from "@excalidraw/element/newElement";
import { isTextElement } from "@excalidraw/element/typeChecks";
import {
isFrameLikeElement,
isTextElement,
} from "@excalidraw/element/typeChecks";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
import { getDefaultFrameName } from "@excalidraw/element/frame";
import type {
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import { atom, useAtom } from "../editor-jotai";
@@ -29,11 +43,17 @@ import { t } from "../i18n";
import { useApp, useExcalidrawSetAppState } from "./App";
import { Button } from "./Button";
import { TextField } from "./TextField";
import { collapseDownIcon, upIcon, searchIcon } from "./icons";
import {
collapseDownIcon,
upIcon,
searchIcon,
frameToolIcon,
TextIcon,
} from "./icons";
import "./SearchMenu.scss";
import type { AppClassProperties } from "../types";
import type { AppClassProperties, SearchMatch } from "../types";
const searchQueryAtom = atom<string>("");
export const searchItemInFocusAtom = atom<number | null>(null);
@@ -41,7 +61,7 @@ export const searchItemInFocusAtom = atom<number | null>(null);
const SEARCH_DEBOUNCE = 350;
type SearchMatchItem = {
textElement: ExcalidrawTextElement;
element: ExcalidrawTextElement | ExcalidrawFrameLikeElement;
searchQuery: SearchQuery;
index: number;
preview: {
@@ -50,12 +70,7 @@ type SearchMatchItem = {
moreBefore: boolean;
moreAfter: boolean;
};
matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[];
matchedLines: SearchMatch["matchedLines"];
};
type SearchMatches = {
@@ -103,11 +118,16 @@ export const SearchMenu = () => {
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
searchMatches: matchItems.length
? {
focusedId: null,
matches: matchItems.map((searchMatch) => ({
id: searchMatch.element.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
}
: null,
});
});
}
@@ -149,13 +169,25 @@ export const SearchMenu = () => {
useEffect(() => {
setAppState((state) => {
if (!state.searchMatches) {
return null;
}
const focusedId =
focusIndex !== null
? state.searchMatches?.matches[focusIndex]?.id || null
: null;
return {
searchMatches: state.searchMatches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
searchMatches: {
focusedId,
matches: state.searchMatches.matches.map((match, index) => {
if (index === focusIndex) {
return { ...match, focus: true };
}
return { ...match, focus: false };
}),
},
};
});
}, [focusIndex, setAppState]);
@@ -169,17 +201,21 @@ export const SearchMenu = () => {
const matchAsElement = newTextElement({
text: match.searchQuery,
x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
x: match.element.x + (match.matchedLines[0]?.offsetX ?? 0),
y: match.element.y + (match.matchedLines[0]?.offsetY ?? 0),
width: match.matchedLines[0]?.width,
height: match.matchedLines[0]?.height,
fontSize: match.textElement.fontSize,
fontFamily: match.textElement.fontFamily,
fontSize: isFrameLikeElement(match.element)
? FRAME_STYLE.nameFontSize
: match.element.fontSize,
fontFamily: isFrameLikeElement(match.element)
? FONT_FAMILY.Assistant
: match.element.fontFamily,
});
const FONT_SIZE_LEGIBILITY_THRESHOLD = 14;
const fontSize = match.textElement.fontSize;
const fontSize = matchAsElement.fontSize;
const isTextTiny =
fontSize * zoomValue < FONT_SIZE_LEGIBILITY_THRESHOLD;
@@ -233,7 +269,7 @@ export const SearchMenu = () => {
searchedQueryRef.current = null;
lastSceneNonceRef.current = undefined;
setAppState({
searchMatches: [],
searchMatches: null,
});
setIsSearching(false);
};
@@ -272,10 +308,6 @@ export const SearchMenu = () => {
}
searchInputRef.current?.focus();
searchInputRef.current?.select();
} else {
setAppState({
openSidebar: null,
});
}
}
@@ -336,11 +368,16 @@ export const SearchMenu = () => {
searchedQueryRef.current = searchQuery;
lastSceneNonceRef.current = app.scene.getSceneNonce();
setAppState({
searchMatches: matchItems.map((searchMatch) => ({
id: searchMatch.textElement.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
searchMatches: matchItems.length
? {
focusedId: null,
matches: matchItems.map((searchMatch) => ({
id: searchMatch.element.id,
focus: false,
matchedLines: searchMatch.matchedLines,
})),
}
: null,
});
setIsSearching(false);
@@ -447,17 +484,56 @@ interface MatchListProps {
}
const MatchListBase = (props: MatchListProps) => {
const frameNameMatches = useMemo(
() =>
props.matches.items.filter((match) => isFrameLikeElement(match.element)),
[props.matches],
);
const textMatches = useMemo(
() => props.matches.items.filter((match) => isTextElement(match.element)),
[props.matches],
);
return (
<div className="layer-ui__search-result-container">
{props.matches.items.map((searchMatch, index) => (
<ListItem
key={searchMatch.textElement.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
<div>
{frameNameMatches.length > 0 && (
<div className="layer-ui__search-result-container">
<div className="layer-ui__search-result-title">
<div className="title-icon">{frameToolIcon}</div>
<div>{t("search.frames")}</div>
</div>
{frameNameMatches.map((searchMatch, index) => (
<ListItem
key={searchMatch.element.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index === props.focusIndex}
onClick={() => props.onItemClick(index)}
/>
))}
{textMatches.length > 0 && <div className="layer-ui__divider" />}
</div>
)}
{textMatches.length > 0 && (
<div className="layer-ui__search-result-container">
<div className="layer-ui__search-result-title">
<div className="title-icon">{TextIcon}</div>
<div>{t("search.texts")}</div>
</div>
{textMatches.map((searchMatch, index) => (
<ListItem
key={searchMatch.element.id + searchMatch.index}
searchQuery={props.searchQuery}
preview={searchMatch.preview}
highlighted={index + frameNameMatches.length === props.focusIndex}
onClick={() => props.onItemClick(index + frameNameMatches.length)}
/>
))}
</div>
)}
</div>
);
};
@@ -592,12 +668,7 @@ const getMatchedLines = (
index,
index + searchQuery.length,
);
const matchedLines: {
offsetX: number;
offsetY: number;
width: number;
height: number;
}[] = [];
const matchedLines: SearchMatch["matchedLines"] = [];
for (const lineIndexRange of lineIndexRanges) {
if (remainingQuery === "") {
@@ -657,6 +728,7 @@ const getMatchedLines = (
offsetY,
width,
height,
showOnCanvas: true,
});
startIndex += matchCapacity;
@@ -666,6 +738,47 @@ const getMatchedLines = (
return matchedLines;
};
const getMatchInFrame = (
frame: ExcalidrawFrameLikeElement,
searchQuery: SearchQuery,
index: number,
zoomValue: number,
): SearchMatch["matchedLines"] => {
const text = frame.name ?? getDefaultFrameName(frame);
const matchedText = text.slice(index, index + searchQuery.length);
const prefixText = text.slice(0, index);
const font = getFontString({
fontSize: FRAME_STYLE.nameFontSize,
fontFamily: FONT_FAMILY.Assistant,
});
const lineHeight = getLineHeight(FONT_FAMILY.Assistant);
const offset = measureText(prefixText, font, lineHeight);
// Correct non-zero width for empty string
if (prefixText === "") {
offset.width = 0;
}
const matchedMetrics = measureText(matchedText, font, lineHeight);
const offsetX = offset.width;
const offsetY = -offset.height - FRAME_STYLE.strokeWidth;
const width = matchedMetrics.width;
return [
{
offsetX,
offsetY,
width,
height: matchedMetrics.height,
showOnCanvas: offsetX + width <= frame.width * zoomValue,
},
];
};
const escapeSpecialCharacters = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
};
@@ -686,9 +799,14 @@ const handleSearch = debounce(
isTextElement(el),
) as ExcalidrawTextElement[];
texts.sort((a, b) => a.y - b.y);
const frames = elements.filter((el) =>
isFrameLikeElement(el),
) as ExcalidrawFrameLikeElement[];
const matchItems: SearchMatchItem[] = [];
texts.sort((a, b) => a.y - b.y);
frames.sort((a, b) => a.y - b.y);
const textMatches: SearchMatchItem[] = [];
const regex = new RegExp(escapeSpecialCharacters(searchQuery), "gi");
@@ -701,8 +819,35 @@ const handleSearch = debounce(
const matchedLines = getMatchedLines(textEl, searchQuery, match.index);
if (matchedLines.length > 0) {
matchItems.push({
textElement: textEl,
textMatches.push({
element: textEl,
searchQuery,
preview,
index: match.index,
matchedLines,
});
}
}
}
const frameMatches: SearchMatchItem[] = [];
for (const frame of frames) {
let match = null;
const name = frame.name ?? getDefaultFrameName(frame);
while ((match = regex.exec(name)) !== null) {
const preview = getMatchPreview(name, match.index, searchQuery);
const matchedLines = getMatchInFrame(
frame,
searchQuery,
match.index,
app.state.zoom.value,
);
if (matchedLines.length > 0) {
frameMatches.push({
element: frame,
searchQuery,
preview,
index: match.index,
@@ -716,9 +861,12 @@ const handleSearch = debounce(
app.visibleElements.map((visibleElement) => visibleElement.id),
);
// putting frame matches first
const matchItems: SearchMatchItem[] = [...frameMatches, ...textMatches];
const focusIndex =
matchItems.findIndex((matchItem) =>
visibleIds.has(matchItem.textElement.id),
visibleIds.has(matchItem.element.id),
) ?? null;
cb(matchItems, focusIndex);
@@ -10,6 +10,7 @@ interface CollapsibleProps {
openTrigger: () => void;
children: React.ReactNode;
className?: string;
showCollapsedIcon?: boolean;
}
const Collapsible = ({
@@ -18,6 +19,7 @@ const Collapsible = ({
openTrigger,
children,
className,
showCollapsedIcon = true,
}: CollapsibleProps) => {
return (
<>
@@ -32,7 +34,9 @@ const Collapsible = ({
onClick={openTrigger}
>
{label}
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
{showCollapsedIcon && (
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
)}
</div>
{open && (
<div style={{ display: "flex", flexDirection: "column" }}>