Ryan Di a30e1b25c6
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>
2025-05-09 18:32:16 +02:00

198 lines
5.8 KiB
TypeScript

import React from "react";
import {
CANVAS_SEARCH_TAB,
CLASSES,
DEFAULT_SIDEBAR,
KEYS,
} from "@excalidraw/common";
import type {
ExcalidrawFrameLikeElement,
ExcalidrawTextElement,
} from "@excalidraw/element/types";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Keyboard } from "./helpers/ui";
import { updateTextEditor } from "./queries/dom";
import { act, render, waitFor } from "./test-utils";
const { h } = window;
const querySearchInput = async () => {
const input =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
)!;
await waitFor(() => expect(input).not.toBeNull());
return input;
};
describe("search", () => {
beforeEach(async () => {
await render(<Excalidraw handleKeyboardGlobally />);
API.setAppState({
openSidebar: null,
});
});
it("should toggle search on cmd+f", async () => {
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
});
it("should refocus search input with cmd+f when search sidebar is still open", async () => {
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
const searchInput =
h.app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
`.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
);
act(() => {
searchInput?.blur();
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(searchInput?.matches(":focus")).toBe(false);
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(searchInput?.matches(":focus")).toBe(true);
});
it("should match text and cycle through matches on Enter", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({ type: "text", text: "test one" }),
API.createElement({ type: "text", text: "test two" }),
]);
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches?.matches.length).toBe(2);
expect(h.app.state.searchMatches?.matches[0].focus).toBe(true);
});
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches?.matches[0].focus).toBe(false);
expect(h.app.state.searchMatches?.matches[1].focus).toBe(true);
Keyboard.keyPress(KEYS.ENTER, searchInput);
expect(h.app.state.searchMatches?.matches[0].focus).toBe(true);
expect(h.app.state.searchMatches?.matches[1].focus).toBe(false);
});
it("should match text split across multiple lines", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({
type: "text",
text: "",
}),
]);
API.updateElement(h.elements[0] as ExcalidrawTextElement, {
text: "t\ne\ns\nt \nt\ne\nx\nt \ns\np\nli\nt \ni\nn\nt\no\nm\nu\nlt\ni\np\nl\ne \nli\nn\ne\ns",
originalText: "test text split into multiple lines",
});
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "test");
await waitFor(() => {
expect(h.app.state.searchMatches?.matches.length).toBe(1);
expect(h.app.state.searchMatches?.matches[0]?.matchedLines?.length).toBe(
4,
);
});
updateTextEditor(searchInput, "ext spli");
await waitFor(() => {
expect(h.app.state.searchMatches?.matches.length).toBe(1);
expect(h.app.state.searchMatches?.matches[0]?.matchedLines?.length).toBe(
6,
);
});
});
it("should match frame names", async () => {
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
API.setElements([
API.createElement({
type: "frame",
}),
]);
API.updateElement(h.elements[0] as ExcalidrawFrameLikeElement, {
name: "Frame: name test for frame, yes, frame!",
});
expect(h.app.state.openSidebar).toBeNull();
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.keyPress(KEYS.F);
});
expect(h.app.state.openSidebar).not.toBeNull();
expect(h.app.state.openSidebar?.name).toBe(DEFAULT_SIDEBAR.name);
expect(h.app.state.openSidebar?.tab).toBe(CANVAS_SEARCH_TAB);
const searchInput = await querySearchInput();
expect(searchInput.matches(":focus")).toBe(true);
updateTextEditor(searchInput, "frame");
await waitFor(() => {
expect(h.app.state.searchMatches?.matches.length).toBe(3);
});
});
});