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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
Reference in New Issue
Block a user