Ver Fonte

feat: canvas search (#8438)

Co-authored-by: dwelle <[email protected]>
Ryan Di há 10 meses atrás
pai
commit
6959a363f0
35 ficheiros alterados com 1424 adições e 47 exclusões
  1. 1 0
      .eslintignore
  2. 1 0
      excalidraw-app/components/AppMainMenu.tsx
  3. 51 0
      packages/excalidraw/actions/actionToggleSearchMenu.ts
  4. 2 0
      packages/excalidraw/actions/index.ts
  5. 3 1
      packages/excalidraw/actions/shortcuts.ts
  6. 4 2
      packages/excalidraw/actions/types.ts
  7. 2 0
      packages/excalidraw/appState.ts
  8. 25 3
      packages/excalidraw/components/App.tsx
  9. 14 1
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  10. 3 3
      packages/excalidraw/components/DefaultSidebar.tsx
  11. 4 0
      packages/excalidraw/components/HelpDialog.tsx
  12. 8 0
      packages/excalidraw/components/HintViewer.tsx
  13. 18 10
      packages/excalidraw/components/LayerUI.tsx
  14. 110 0
      packages/excalidraw/components/SearchMenu.scss
  15. 671 0
      packages/excalidraw/components/SearchMenu.tsx
  16. 29 0
      packages/excalidraw/components/SearchSidebar.tsx
  17. 21 7
      packages/excalidraw/components/TextField.scss
  18. 8 2
      packages/excalidraw/components/TextField.tsx
  19. 1 0
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  20. 8 0
      packages/excalidraw/components/icons.tsx
  21. 23 1
      packages/excalidraw/components/main-menu/DefaultItems.tsx
  22. 5 0
      packages/excalidraw/constants.ts
  23. 3 3
      packages/excalidraw/css/theme.scss
  24. 4 3
      packages/excalidraw/element/textElement.ts
  25. 8 0
      packages/excalidraw/locales/en.json
  26. 46 3
      packages/excalidraw/renderer/interactiveScene.ts
  27. 17 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  28. 49 0
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  29. 58 0
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  30. 52 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  31. 13 7
      packages/excalidraw/tests/helpers/ui.ts
  32. 1 1
      packages/excalidraw/tests/queries/dom.ts
  33. 143 0
      packages/excalidraw/tests/search.test.tsx
  34. 17 0
      packages/excalidraw/types.ts
  35. 1 0
      packages/utils/__snapshots__/export.test.ts.snap

+ 1 - 0
.eslintignore

@@ -8,3 +8,4 @@ public/workbox
 packages/excalidraw/types
 examples/**/public
 dev-dist
+coverage

+ 1 - 0
excalidraw-app/components/AppMainMenu.tsx

@@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{
         />
       )}
       <MainMenu.DefaultItems.CommandPalette className="highlighted" />
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />

+ 51 - 0
packages/excalidraw/actions/actionToggleSearchMenu.ts

@@ -0,0 +1,51 @@
+import { KEYS } from "../keys";
+import { register } from "./register";
+import type { AppState } from "../types";
+import { searchIcon } from "../components/icons";
+import { StoreAction } from "../store";
+import { CLASSES, SEARCH_SIDEBAR } from "../constants";
+
+export const actionToggleSearchMenu = register({
+  name: "searchMenu",
+  icon: searchIcon,
+  keywords: ["search", "find"],
+  label: "search.title",
+  viewMode: true,
+  trackEvent: {
+    category: "search_menu",
+    action: "toggle",
+    predicate: (appState) => appState.gridModeEnabled,
+  },
+  perform(elements, appState, _, app) {
+    if (appState.openSidebar?.name === SEARCH_SIDEBAR.name) {
+      const searchInput =
+        app.excalidrawContainerValue.container?.querySelector<HTMLInputElement>(
+          `.${CLASSES.SEARCH_MENU_INPUT_WRAPPER} input`,
+        );
+
+      if (searchInput?.matches(":focus")) {
+        return {
+          appState: { ...appState, openSidebar: null },
+          storeAction: StoreAction.NONE,
+        };
+      }
+
+      searchInput?.focus();
+      return false;
+    }
+
+    return {
+      appState: {
+        ...appState,
+        openSidebar: { name: SEARCH_SIDEBAR.name },
+        openDialog: null,
+      },
+      storeAction: StoreAction.NONE,
+    };
+  },
+  checked: (appState: AppState) => appState.gridModeEnabled,
+  predicate: (element, appState, props) => {
+    return props.gridModeEnabled === undefined;
+  },
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F,
+});

+ 2 - 0
packages/excalidraw/actions/index.ts

@@ -86,3 +86,5 @@ export { actionUnbindText, actionBindText } from "./actionBoundText";
 export { actionLink } from "./actionLink";
 export { actionToggleElementLock } from "./actionElementLock";
 export { actionToggleLinearEditor } from "./actionLinearEditor";
+
+export { actionToggleSearchMenu } from "./actionToggleSearchMenu";

+ 3 - 1
packages/excalidraw/actions/shortcuts.ts

@@ -51,7 +51,8 @@ export type ShortcutName =
     >
   | "saveScene"
   | "imageExport"
-  | "commandPalette";
+  | "commandPalette"
+  | "searchMenu";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   toggleTheme: [getShortcutKey("Shift+Alt+D")],
@@ -112,6 +113,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")],
   saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")],
   toggleShortcuts: [getShortcutKey("?")],
+  searchMenu: [getShortcutKey("CtrlOrCmd+F")],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => {

+ 4 - 2
packages/excalidraw/actions/types.ts

@@ -137,7 +137,8 @@ export type ActionName =
   | "wrapTextInContainer"
   | "commandPalette"
   | "autoResize"
-  | "elementStats";
+  | "elementStats"
+  | "searchMenu";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];
@@ -191,7 +192,8 @@ export interface Action {
           | "history"
           | "menu"
           | "collab"
-          | "hyperlink";
+          | "hyperlink"
+          | "search_menu";
         action?: string;
         predicate?: (
           appState: Readonly<AppState>,

+ 2 - 0
packages/excalidraw/appState.ts

@@ -116,6 +116,7 @@ export const getDefaultAppState = (): Omit<
     objectsSnapModeEnabled: false,
     userToFollow: null,
     followedBy: new Set(),
+    searchMatches: [],
   };
 };
 
@@ -236,6 +237,7 @@ const APP_STATE_STORAGE_CONF = (<
   objectsSnapModeEnabled: { browser: true, export: false, server: false },
   userToFollow: { browser: false, export: false, server: false },
   followedBy: { browser: false, export: false, server: false },
+  searchMatches: { browser: false, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 25 - 3
packages/excalidraw/components/App.tsx

@@ -440,6 +440,7 @@ import {
   FlowChartNavigator,
   getLinkDirectionFromKey,
 } from "../element/flowchart";
+import { searchItemInFocusAtom } from "./SearchMenu";
 import type { LocalPoint, Radians } from "../../math";
 import { point, pointDistance, vector } from "../../math";
 
@@ -548,6 +549,7 @@ class App extends React.Component<AppProps, AppState> {
   public scene: Scene;
   public fonts: Fonts;
   public renderer: Renderer;
+  public visibleElements: readonly NonDeletedExcalidrawElement[];
   private resizeObserver: ResizeObserver | undefined;
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   public library: AppClassProperties["library"];
@@ -555,7 +557,7 @@ class App extends React.Component<AppProps, AppState> {
   public id: string;
   private store: Store;
   private history: History;
-  private excalidrawContainerValue: {
+  public excalidrawContainerValue: {
     container: HTMLDivElement | null;
     id: string;
   };
@@ -682,6 +684,7 @@ class App extends React.Component<AppProps, AppState> {
     this.canvas = document.createElement("canvas");
     this.rc = rough.canvas(this.canvas);
     this.renderer = new Renderer(this.scene);
+    this.visibleElements = [];
 
     this.store = new Store();
     this.history = new History();
@@ -1480,6 +1483,7 @@ class App extends React.Component<AppProps, AppState> {
         newElementId: this.state.newElement?.id,
         pendingImageElementId: this.state.pendingImageElementId,
       });
+    this.visibleElements = visibleElements;
 
     const allElementsMap = this.scene.getNonDeletedElementsMap();
 
@@ -3800,7 +3804,7 @@ class App extends React.Component<AppProps, AppState> {
     },
   );
 
-  private getEditorUIOffsets = (): {
+  public getEditorUIOffsets = (): {
     top: number;
     right: number;
     bottom: number;
@@ -5973,6 +5977,16 @@ class App extends React.Component<AppProps, AppState> {
     this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
     this.maybeUnfollowRemoteUser();
 
+    if (this.state.searchMatches) {
+      this.setState((state) => ({
+        searchMatches: state.searchMatches.map((searchMatch) => ({
+          ...searchMatch,
+          focus: false,
+        })),
+      }));
+      jotaiStore.set(searchItemInFocusAtom, null);
+    }
+
     // since contextMenu options are potentially evaluated on each render,
     // and an contextMenu action may depend on selection state, we must
     // close the contextMenu before we update the selection on pointerDown
@@ -6401,8 +6415,16 @@ class App extends React.Component<AppProps, AppState> {
     }
     isPanning = true;
 
+    // due to event.preventDefault below, container wouldn't get focus
+    // automatically
+    this.focusContainer();
+
+    // preventing defualt while text editing messes with cursor/focus
     if (!this.state.editingTextElement) {
-      // preventing defualt while text editing messes with cursor/focus
+      // necessary to prevent browser from scrolling the page if excalidraw
+      // not full-page #4489
+      //
+      // as such, the above is broken when panning canvas while in wysiwyg
       event.preventDefault();
     }
 

+ 14 - 1
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -43,7 +43,11 @@ import { InlineIcon } from "../InlineIcon";
 import { SHAPES } from "../../shapes";
 import { canChangeBackgroundColor, canChangeStrokeColor } from "../Actions";
 import { useStableCallback } from "../../hooks/useStableCallback";
-import { actionClearCanvas, actionLink } from "../../actions";
+import {
+  actionClearCanvas,
+  actionLink,
+  actionToggleSearchMenu,
+} from "../../actions";
 import { jotaiStore } from "../../jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 import type { CommandPaletteItem } from "./types";
@@ -382,6 +386,15 @@ function CommandPaletteInner({
             }
           },
         },
+        {
+          label: t("search.title"),
+          category: DEFAULT_CATEGORIES.app,
+          icon: searchIcon,
+          viewMode: true,
+          perform: () => {
+            actionManager.executeAction(actionToggleSearchMenu);
+          },
+        },
         {
           label: t("labels.changeStroke"),
           keywords: ["color", "outline"],

+ 3 - 3
packages/excalidraw/components/DefaultSidebar.tsx

@@ -2,7 +2,6 @@ import clsx from "clsx";
 import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
 import { useTunnels } from "../context/tunnels";
 import { useUIAppState } from "../context/ui-appState";
-import { t } from "../i18n";
 import type { MarkOptional, Merge } from "../utility-types";
 import { composeEventHandlers } from "../utils";
 import { useExcalidrawSetAppState } from "./App";
@@ -10,6 +9,8 @@ import { withInternalFallback } from "./hoc/withInternalFallback";
 import { LibraryMenu } from "./LibraryMenu";
 import type { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
 import { Sidebar } from "./Sidebar/Sidebar";
+import "../components/dropdownMenu/DropdownMenu.scss";
+import { t } from "../i18n";
 
 const DefaultSidebarTrigger = withInternalFallback(
   "DefaultSidebarTrigger",
@@ -68,8 +69,7 @@ export const DefaultSidebar = Object.assign(
       return (
         <Sidebar
           {...rest}
-          name="default"
-          key="default"
+          name={"default"}
           className={clsx("default-sidebar", className)}
           docked={docked ?? appState.defaultSidebarDockedPreference}
           onDock={

+ 4 - 0
packages/excalidraw/components/HelpDialog.tsx

@@ -288,6 +288,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("stats.fullTitle")}
               shortcuts={[getShortcutKey("Alt+/")]}
             />
+            <Shortcut
+              label={t("search.title")}
+              shortcuts={[getShortcutFromShortcutName("searchMenu")]}
+            />
             <Shortcut
               label={t("commandPalette.title")}
               shortcuts={

+ 8 - 0
packages/excalidraw/components/HintViewer.tsx

@@ -13,6 +13,7 @@ import { isEraserActive } from "../appState";
 import "./HintViewer.scss";
 import { isNodeInFlowchart } from "../element/flowchart";
 import { isGridModeEnabled } from "../snapping";
+import { SEARCH_SIDEBAR } from "../constants";
 
 interface HintViewerProps {
   appState: UIAppState;
@@ -30,6 +31,13 @@ const getHints = ({
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
+  if (
+    appState.openSidebar?.name === SEARCH_SIDEBAR.name &&
+    appState.searchMatches?.length
+  ) {
+    return t("hints.dismissSearch");
+  }
+
   if (appState.openSidebar && !device.editor.canFitSidebar) {
     return null;
   }

+ 18 - 10
packages/excalidraw/components/LayerUI.tsx

@@ -5,6 +5,7 @@ import {
   CLASSES,
   DEFAULT_SIDEBAR,
   LIBRARY_SIDEBAR_WIDTH,
+  SEARCH_SIDEBAR,
   TOOL_TYPE,
 } from "../constants";
 import { showSelectedShapeActions } from "../element";
@@ -63,6 +64,7 @@ import { LaserPointerButton } from "./LaserPointerButton";
 import { TTDDialog } from "./TTDDialog/TTDDialog";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
+import { SearchSidebar } from "./SearchSidebar";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -99,6 +101,7 @@ const DefaultMainMenu: React.FC<{
       {UIOptions.canvasActions.saveAsImage && (
         <MainMenu.DefaultItems.SaveAsImage />
       )}
+      <MainMenu.DefaultItems.SearchMenu />
       <MainMenu.DefaultItems.Help />
       <MainMenu.DefaultItems.ClearCanvas />
       <MainMenu.Separator />
@@ -362,16 +365,21 @@ const LayerUI = ({
 
   const renderSidebars = () => {
     return (
-      <DefaultSidebar
-        __fallback
-        onDock={(docked) => {
-          trackEvent(
-            "sidebar",
-            `toggleDock (${docked ? "dock" : "undock"})`,
-            `(${device.editor.isMobile ? "mobile" : "desktop"})`,
-          );
-        }}
-      />
+      <>
+        {appState.openSidebar?.name === SEARCH_SIDEBAR.name && (
+          <SearchSidebar />
+        )}
+        <DefaultSidebar
+          __fallback
+          onDock={(docked) => {
+            trackEvent(
+              "sidebar",
+              `toggleDock (${docked ? "dock" : "undock"})`,
+              `(${device.editor.isMobile ? "mobile" : "desktop"})`,
+            );
+          }}
+        />
+      </>
     );
   };
 

+ 110 - 0
packages/excalidraw/components/SearchMenu.scss

@@ -0,0 +1,110 @@
+@import "open-color/open-color";
+
+.excalidraw {
+  .layer-ui__search {
+    flex: 1 0 auto;
+    display: flex;
+    flex-direction: column;
+    padding: 8px 0 0 0;
+  }
+
+  .layer-ui__search-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0 0.75rem;
+    .ExcTextField {
+      flex: 1 0 auto;
+    }
+
+    .ExcTextField__input {
+      background-color: #f5f5f9;
+      @at-root .excalidraw.theme--dark#{&} {
+        background-color: #31303b;
+      }
+
+      border-radius: var(--border-radius-md);
+      border: 0;
+
+      input::placeholder {
+        font-size: 0.9rem;
+      }
+    }
+  }
+
+  .layer-ui__search-count {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 8px 0 8px;
+    margin: 0 0.75rem 0.25rem 0.75rem;
+    font-size: 0.8em;
+
+    .result-nav {
+      display: flex;
+
+      .result-nav-btn {
+        width: 36px;
+        height: 36px;
+        --button-border: transparent;
+
+        &:active {
+          background-color: var(--color-surface-high);
+        }
+
+        &:first {
+          margin-right: 4px;
+        }
+      }
+    }
+  }
+
+  .layer-ui__search-result-container {
+    overflow-y: auto;
+    flex: 1 1 0;
+    display: flex;
+    flex-direction: column;
+
+    gap: 0.125rem;
+  }
+
+  .layer-ui__result-item {
+    display: flex;
+    align-items: center;
+    min-height: 2rem;
+    flex: 0 0 auto;
+    padding: 0.25rem 0.75rem;
+    cursor: pointer;
+    border: 1px solid transparent;
+    outline: none;
+
+    margin: 0 0.75rem;
+    border-radius: var(--border-radius-md);
+
+    .text-icon {
+      width: 1rem;
+      height: 1rem;
+      margin-right: 0.75rem;
+    }
+
+    .preview-text {
+      flex: 1;
+      max-height: 48px;
+      line-height: 24px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      word-break: break-all;
+    }
+
+    &:hover {
+      background-color: var(--color-surface-high);
+    }
+    &:active {
+      border-color: var(--color-primary);
+    }
+
+    &.active {
+      background-color: var(--color-surface-high);
+    }
+  }
+}

+ 671 - 0
packages/excalidraw/components/SearchMenu.tsx

@@ -0,0 +1,671 @@
+import { Fragment, memo, useEffect, useRef, useState } from "react";
+import { collapseDownIcon, upIcon, searchIcon } from "./icons";
+import { TextField } from "./TextField";
+import { Button } from "./Button";
+import { useApp, useExcalidrawSetAppState } from "./App";
+import { debounce } from "lodash";
+import type { AppClassProperties } from "../types";
+import { isTextElement, newTextElement } from "../element";
+import type { ExcalidrawTextElement } from "../element/types";
+import { measureText } from "../element/textElement";
+import { addEventListener, getFontString } from "../utils";
+import { KEYS } from "../keys";
+
+import "./SearchMenu.scss";
+import clsx from "clsx";
+import { atom, useAtom } from "jotai";
+import { jotaiScope } from "../jotai";
+import { t } from "../i18n";
+import { isElementCompletelyInViewport } from "../element/sizeHelpers";
+import { randomInteger } from "../random";
+import { CLASSES, EVENT } from "../constants";
+import { useStable } from "../hooks/useStable";
+
+const searchKeywordAtom = atom<string>("");
+export const searchItemInFocusAtom = atom<number | null>(null);
+
+const SEARCH_DEBOUNCE = 350;
+
+type SearchMatchItem = {
+  textElement: ExcalidrawTextElement;
+  keyword: string;
+  index: number;
+  preview: {
+    indexInKeyword: number;
+    previewText: string;
+    moreBefore: boolean;
+    moreAfter: boolean;
+  };
+  matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[];
+};
+
+type SearchMatches = {
+  nonce: number | null;
+  items: SearchMatchItem[];
+};
+
+export const SearchMenu = () => {
+  const app = useApp();
+  const setAppState = useExcalidrawSetAppState();
+
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
+  const [keyword, setKeyword] = useAtom(searchKeywordAtom, jotaiScope);
+  const [searchMatches, setSearchMatches] = useState<SearchMatches>({
+    nonce: null,
+    items: [],
+  });
+  const searchedKeywordRef = useRef<string | null>();
+  const lastSceneNonceRef = useRef<number | undefined>();
+
+  const [focusIndex, setFocusIndex] = useAtom(
+    searchItemInFocusAtom,
+    jotaiScope,
+  );
+  const elementsMap = app.scene.getNonDeletedElementsMap();
+
+  useEffect(() => {
+    const trimmedKeyword = keyword.trim();
+    if (
+      trimmedKeyword !== searchedKeywordRef.current ||
+      app.scene.getSceneNonce() !== lastSceneNonceRef.current
+    ) {
+      searchedKeywordRef.current = null;
+      handleSearch(trimmedKeyword, app, (matchItems, index) => {
+        setSearchMatches({
+          nonce: randomInteger(),
+          items: matchItems,
+        });
+        setFocusIndex(index);
+        searchedKeywordRef.current = trimmedKeyword;
+        lastSceneNonceRef.current = app.scene.getSceneNonce();
+        setAppState({
+          searchMatches: matchItems.map((searchMatch) => ({
+            id: searchMatch.textElement.id,
+            focus: false,
+            matchedLines: searchMatch.matchedLines,
+          })),
+        });
+      });
+    }
+  }, [
+    keyword,
+    elementsMap,
+    app,
+    setAppState,
+    setFocusIndex,
+    lastSceneNonceRef,
+  ]);
+
+  const goToNextItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return (focusIndex + 1) % searchMatches.items.length;
+      });
+    }
+  };
+
+  const goToPreviousItem = () => {
+    if (searchMatches.items.length > 0) {
+      setFocusIndex((focusIndex) => {
+        if (focusIndex === null) {
+          return 0;
+        }
+
+        return focusIndex - 1 < 0
+          ? searchMatches.items.length - 1
+          : focusIndex - 1;
+      });
+    }
+  };
+
+  useEffect(() => {
+    if (searchMatches.items.length > 0 && focusIndex !== null) {
+      const match = searchMatches.items[focusIndex];
+
+      if (match) {
+        const matchAsElement = newTextElement({
+          text: match.keyword,
+          x: match.textElement.x + (match.matchedLines[0]?.offsetX ?? 0),
+          y: match.textElement.y + (match.matchedLines[0]?.offsetY ?? 0),
+          width: match.matchedLines[0]?.width,
+          height: match.matchedLines[0]?.height,
+        });
+
+        if (
+          !isElementCompletelyInViewport(
+            [matchAsElement],
+            app.canvas.width / window.devicePixelRatio,
+            app.canvas.height / window.devicePixelRatio,
+            {
+              offsetLeft: app.state.offsetLeft,
+              offsetTop: app.state.offsetTop,
+              scrollX: app.state.scrollX,
+              scrollY: app.state.scrollY,
+              zoom: app.state.zoom,
+            },
+            app.scene.getNonDeletedElementsMap(),
+            app.getEditorUIOffsets(),
+          )
+        ) {
+          app.scrollToContent(matchAsElement, {
+            fitToContent: true,
+            animate: true,
+            duration: 300,
+          });
+        }
+
+        const nextMatches = searchMatches.items.map((match, index) => {
+          if (index === focusIndex) {
+            return {
+              id: match.textElement.id,
+              focus: true,
+              matchedLines: match.matchedLines,
+            };
+          }
+          return {
+            id: match.textElement.id,
+            focus: false,
+            matchedLines: match.matchedLines,
+          };
+        });
+
+        setAppState({
+          searchMatches: nextMatches,
+        });
+      }
+    }
+  }, [app, focusIndex, searchMatches, setAppState]);
+
+  useEffect(() => {
+    return () => {
+      setFocusIndex(null);
+      searchedKeywordRef.current = null;
+      lastSceneNonceRef.current = undefined;
+      setAppState({
+        searchMatches: [],
+      });
+    };
+  }, [setAppState, setFocusIndex]);
+
+  const stableState = useStable({
+    goToNextItem,
+    goToPreviousItem,
+    searchMatches,
+  });
+
+  useEffect(() => {
+    const eventHandler = (event: KeyboardEvent) => {
+      if (
+        event.key === KEYS.ESCAPE &&
+        !app.state.openDialog &&
+        !app.state.openPopup
+      ) {
+        event.preventDefault();
+        event.stopPropagation();
+        setAppState({
+          openSidebar: null,
+        });
+        return;
+      }
+
+      if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.F) {
+        event.preventDefault();
+        event.stopPropagation();
+
+        if (!searchInputRef.current?.matches(":focus")) {
+          if (app.state.openDialog) {
+            setAppState({
+              openDialog: null,
+            });
+          }
+          searchInputRef.current?.focus();
+        } else {
+          setAppState({
+            openSidebar: null,
+          });
+        }
+      }
+
+      if (
+        event.target instanceof HTMLElement &&
+        event.target.closest(".layer-ui__search")
+      ) {
+        if (stableState.searchMatches.items.length) {
+          if (event.key === KEYS.ENTER) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+
+          if (event.key === KEYS.ARROW_UP) {
+            event.stopPropagation();
+            stableState.goToPreviousItem();
+          } else if (event.key === KEYS.ARROW_DOWN) {
+            event.stopPropagation();
+            stableState.goToNextItem();
+          }
+        }
+      }
+    };
+
+    // `capture` needed to prevent firing on initial open from App.tsx,
+    // as well as to handle events before App ones
+    return addEventListener(window, EVENT.KEYDOWN, eventHandler, {
+      capture: true,
+    });
+  }, [setAppState, stableState, app]);
+
+  /**
+   * NOTE:
+   *
+   * for testing purposes, we're manually focusing instead of
+   * setting `selectOnRender` on <TextField>
+   */
+  useEffect(() => {
+    searchInputRef.current?.focus();
+  }, []);
+
+  const matchCount = `${searchMatches.items.length} ${
+    searchMatches.items.length === 1
+      ? t("search.singleResult")
+      : t("search.multipleResults")
+  }`;
+
+  return (
+    <div className="layer-ui__search">
+      <div className="layer-ui__search-header">
+        <TextField
+          className={CLASSES.SEARCH_MENU_INPUT_WRAPPER}
+          value={keyword}
+          ref={searchInputRef}
+          placeholder={t("search.placeholder")}
+          icon={searchIcon}
+          onChange={(value) => {
+            setKeyword(value);
+          }}
+        />
+      </div>
+
+      <div className="layer-ui__search-count">
+        {searchMatches.items.length > 0 && (
+          <>
+            {focusIndex !== null && focusIndex > -1 ? (
+              <div>
+                {focusIndex + 1} / {matchCount}
+              </div>
+            ) : (
+              <div>{matchCount}</div>
+            )}
+            <div className="result-nav">
+              <Button
+                onSelect={() => {
+                  goToNextItem();
+                }}
+                className="result-nav-btn"
+              >
+                {collapseDownIcon}
+              </Button>
+              <Button
+                onSelect={() => {
+                  goToPreviousItem();
+                }}
+                className="result-nav-btn"
+              >
+                {upIcon}
+              </Button>
+            </div>
+          </>
+        )}
+
+        {searchMatches.items.length === 0 &&
+          keyword &&
+          searchedKeywordRef.current && (
+            <div style={{ margin: "1rem auto" }}>{t("search.noMatch")}</div>
+          )}
+      </div>
+
+      <MatchList
+        matches={searchMatches}
+        onItemClick={setFocusIndex}
+        focusIndex={focusIndex}
+        trimmedKeyword={keyword.trim()}
+      />
+    </div>
+  );
+};
+
+const ListItem = (props: {
+  preview: SearchMatchItem["preview"];
+  trimmedKeyword: string;
+  highlighted: boolean;
+  onClick?: () => void;
+}) => {
+  const preview = [
+    props.preview.moreBefore ? "..." : "",
+    props.preview.previewText.slice(0, props.preview.indexInKeyword),
+    props.preview.previewText.slice(
+      props.preview.indexInKeyword,
+      props.preview.indexInKeyword + props.trimmedKeyword.length,
+    ),
+    props.preview.previewText.slice(
+      props.preview.indexInKeyword + props.trimmedKeyword.length,
+    ),
+    props.preview.moreAfter ? "..." : "",
+  ];
+
+  return (
+    <div
+      tabIndex={-1}
+      className={clsx("layer-ui__result-item", {
+        active: props.highlighted,
+      })}
+      onClick={props.onClick}
+      ref={(ref) => {
+        if (props.highlighted) {
+          ref?.scrollIntoView({ behavior: "auto", block: "nearest" });
+        }
+      }}
+    >
+      <div className="preview-text">
+        {preview.flatMap((text, idx) => (
+          <Fragment key={idx}>{idx === 2 ? <b>{text}</b> : text}</Fragment>
+        ))}
+      </div>
+    </div>
+  );
+};
+
+interface MatchListProps {
+  matches: SearchMatches;
+  onItemClick: (index: number) => void;
+  focusIndex: number | null;
+  trimmedKeyword: string;
+}
+
+const MatchListBase = (props: MatchListProps) => {
+  return (
+    <div className="layer-ui__search-result-container">
+      {props.matches.items.map((searchMatch, index) => (
+        <ListItem
+          key={searchMatch.textElement.id + searchMatch.index}
+          trimmedKeyword={props.trimmedKeyword}
+          preview={searchMatch.preview}
+          highlighted={index === props.focusIndex}
+          onClick={() => props.onItemClick(index)}
+        />
+      ))}
+    </div>
+  );
+};
+
+const areEqual = (prevProps: MatchListProps, nextProps: MatchListProps) => {
+  return (
+    prevProps.matches.nonce === nextProps.matches.nonce &&
+    prevProps.focusIndex === nextProps.focusIndex
+  );
+};
+
+const MatchList = memo(MatchListBase, areEqual);
+
+const getMatchPreview = (text: string, index: number, keyword: string) => {
+  const WORDS_BEFORE = 2;
+  const WORDS_AFTER = 5;
+
+  const substrBeforeKeyword = text.slice(0, index);
+  const wordsBeforeKeyword = substrBeforeKeyword.split(/\s+/);
+  // text = "small", keyword = "mall", not complete before
+  // text = "small", keyword = "smal", complete before
+  const isKeywordCompleteBefore = substrBeforeKeyword.endsWith(" ");
+  const startWordIndex =
+    wordsBeforeKeyword.length -
+    WORDS_BEFORE -
+    1 -
+    (isKeywordCompleteBefore ? 0 : 1);
+  let wordsBeforeAsString =
+    wordsBeforeKeyword
+      .slice(startWordIndex <= 0 ? 0 : startWordIndex)
+      .join(" ") + (isKeywordCompleteBefore ? " " : "");
+
+  const MAX_ALLOWED_CHARS = 20;
+
+  wordsBeforeAsString =
+    wordsBeforeAsString.length > MAX_ALLOWED_CHARS
+      ? wordsBeforeAsString.slice(-MAX_ALLOWED_CHARS)
+      : wordsBeforeAsString;
+
+  const substrAfterKeyword = text.slice(index + keyword.length);
+  const wordsAfter = substrAfterKeyword.split(/\s+/);
+  // text = "small", keyword = "mall", complete after
+  // text = "small", keyword = "smal", not complete after
+  const isKeywordCompleteAfter = !substrAfterKeyword.startsWith(" ");
+  const numberOfWordsToTake = isKeywordCompleteAfter
+    ? WORDS_AFTER + 1
+    : WORDS_AFTER;
+  const wordsAfterAsString =
+    (isKeywordCompleteAfter ? "" : " ") +
+    wordsAfter.slice(0, numberOfWordsToTake).join(" ");
+
+  return {
+    indexInKeyword: wordsBeforeAsString.length,
+    previewText: wordsBeforeAsString + keyword + wordsAfterAsString,
+    moreBefore: startWordIndex > 0,
+    moreAfter: wordsAfter.length > numberOfWordsToTake,
+  };
+};
+
+const normalizeWrappedText = (
+  wrappedText: string,
+  originalText: string,
+): string => {
+  const wrappedLines = wrappedText.split("\n");
+  const normalizedLines: string[] = [];
+  let originalIndex = 0;
+
+  for (let i = 0; i < wrappedLines.length; i++) {
+    let currentLine = wrappedLines[i];
+    const nextLine = wrappedLines[i + 1];
+
+    if (nextLine) {
+      const nextLineIndexInOriginal = originalText.indexOf(
+        nextLine,
+        originalIndex,
+      );
+
+      if (nextLineIndexInOriginal > currentLine.length + originalIndex) {
+        let j = nextLineIndexInOriginal - (currentLine.length + originalIndex);
+
+        while (j > 0) {
+          currentLine += " ";
+          j--;
+        }
+      }
+    }
+
+    normalizedLines.push(currentLine);
+    originalIndex = originalIndex + currentLine.length;
+  }
+
+  return normalizedLines.join("\n");
+};
+
+const getMatchedLines = (
+  textElement: ExcalidrawTextElement,
+  keyword: string,
+  index: number,
+) => {
+  const normalizedText = normalizeWrappedText(
+    textElement.text,
+    textElement.originalText,
+  );
+
+  const lines = normalizedText.split("\n");
+
+  const lineIndexRanges = [];
+  let currentIndex = 0;
+  let lineNumber = 0;
+
+  for (const line of lines) {
+    const startIndex = currentIndex;
+    const endIndex = startIndex + line.length - 1;
+
+    lineIndexRanges.push({
+      line,
+      startIndex,
+      endIndex,
+      lineNumber,
+    });
+
+    // Move to the next line's start index
+    currentIndex = endIndex + 1;
+    lineNumber++;
+  }
+
+  let startIndex = index;
+  let remainingKeyword = textElement.originalText.slice(
+    index,
+    index + keyword.length,
+  );
+  const matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[] = [];
+
+  for (const lineIndexRange of lineIndexRanges) {
+    if (remainingKeyword === "") {
+      break;
+    }
+
+    if (
+      startIndex >= lineIndexRange.startIndex &&
+      startIndex <= lineIndexRange.endIndex
+    ) {
+      const matchCapacity = lineIndexRange.endIndex + 1 - startIndex;
+      const textToStart = lineIndexRange.line.slice(
+        0,
+        startIndex - lineIndexRange.startIndex,
+      );
+
+      const matchedWord = remainingKeyword.slice(0, matchCapacity);
+      remainingKeyword = remainingKeyword.slice(matchCapacity);
+
+      const offset = measureText(
+        textToStart,
+        getFontString(textElement),
+        textElement.lineHeight,
+        true,
+      );
+
+      // measureText returns a non-zero width for the empty string
+      // which is not what we're after here, hence the check and the correction
+      if (textToStart === "") {
+        offset.width = 0;
+      }
+
+      if (textElement.textAlign !== "left" && lineIndexRange.line.length > 0) {
+        const lineLength = measureText(
+          lineIndexRange.line,
+          getFontString(textElement),
+          textElement.lineHeight,
+          true,
+        );
+
+        const spaceToStart =
+          textElement.textAlign === "center"
+            ? (textElement.width - lineLength.width) / 2
+            : textElement.width - lineLength.width;
+        offset.width += spaceToStart;
+      }
+
+      const { width, height } = measureText(
+        matchedWord,
+        getFontString(textElement),
+        textElement.lineHeight,
+      );
+
+      const offsetX = offset.width;
+      const offsetY = lineIndexRange.lineNumber * offset.height;
+
+      matchedLines.push({
+        offsetX,
+        offsetY,
+        width,
+        height,
+      });
+
+      startIndex += matchCapacity;
+    }
+  }
+
+  return matchedLines;
+};
+
+const escapeSpecialCharacters = (string: string) => {
+  return string.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
+};
+
+const handleSearch = debounce(
+  (
+    keyword: string,
+    app: AppClassProperties,
+    cb: (matchItems: SearchMatchItem[], focusIndex: number | null) => void,
+  ) => {
+    if (!keyword || keyword === "") {
+      cb([], null);
+      return;
+    }
+
+    const elements = app.scene.getNonDeletedElements();
+    const texts = elements.filter((el) =>
+      isTextElement(el),
+    ) as ExcalidrawTextElement[];
+
+    texts.sort((a, b) => a.y - b.y);
+
+    const matchItems: SearchMatchItem[] = [];
+
+    const regex = new RegExp(escapeSpecialCharacters(keyword), "gi");
+
+    for (const textEl of texts) {
+      let match = null;
+      const text = textEl.originalText;
+
+      while ((match = regex.exec(text)) !== null) {
+        const preview = getMatchPreview(text, match.index, keyword);
+        const matchedLines = getMatchedLines(textEl, keyword, match.index);
+
+        if (matchedLines.length > 0) {
+          matchItems.push({
+            textElement: textEl,
+            keyword,
+            preview,
+            index: match.index,
+            matchedLines,
+          });
+        }
+      }
+    }
+
+    const visibleIds = new Set(
+      app.visibleElements.map((visibleElement) => visibleElement.id),
+    );
+
+    const focusIndex =
+      matchItems.findIndex((matchItem) =>
+        visibleIds.has(matchItem.textElement.id),
+      ) ?? null;
+
+    cb(matchItems, focusIndex);
+  },
+  SEARCH_DEBOUNCE,
+);

+ 29 - 0
packages/excalidraw/components/SearchSidebar.tsx

@@ -0,0 +1,29 @@
+import { SEARCH_SIDEBAR } from "../constants";
+import { t } from "../i18n";
+import { SearchMenu } from "./SearchMenu";
+import { Sidebar } from "./Sidebar/Sidebar";
+
+export const SearchSidebar = () => {
+  return (
+    <Sidebar name={SEARCH_SIDEBAR.name} docked>
+      <Sidebar.Tabs>
+        <Sidebar.Header>
+          <div
+            style={{
+              color: "var(--color-primary)",
+              fontSize: "1.2em",
+              fontWeight: "bold",
+              textOverflow: "ellipsis",
+              overflow: "hidden",
+              whiteSpace: "nowrap",
+              paddingRight: "1em",
+            }}
+          >
+            {t("search.title")}
+          </div>
+        </Sidebar.Header>
+        <SearchMenu />
+      </Sidebar.Tabs>
+    </Sidebar>
+  );
+};

+ 21 - 7
packages/excalidraw/components/TextField.scss

@@ -3,16 +3,29 @@
 .excalidraw {
   --ExcTextField--color: var(--color-on-surface);
   --ExcTextField--label-color: var(--color-on-surface);
-  --ExcTextField--background: transparent;
+  --ExcTextField--background: var(--color-surface-low);
   --ExcTextField--readonly--background: var(--color-surface-high);
   --ExcTextField--readonly--color: var(--color-on-surface);
-  --ExcTextField--border: var(--color-border-outline);
+  --ExcTextField--border: var(--color-gray-20);
   --ExcTextField--readonly--border: var(--color-border-outline-variant);
   --ExcTextField--border-hover: var(--color-brand-hover);
   --ExcTextField--border-active: var(--color-brand-active);
   --ExcTextField--placeholder: var(--color-border-outline-variant);
 
   .ExcTextField {
+    position: relative;
+
+    svg {
+      position: absolute;
+      top: 50%; // 50% is not exactly in the center of the input
+      transform: translateY(-50%);
+      left: 0.75rem;
+      width: 1.25rem;
+      height: 1.25rem;
+      color: var(--color-gray-40);
+      z-index: 1;
+    }
+
     &--fullWidth {
       width: 100%;
       flex-grow: 1;
@@ -37,7 +50,6 @@
       display: flex;
       flex-direction: row;
       align-items: center;
-      padding: 0 1rem;
 
       height: 3rem;
 
@@ -45,6 +57,8 @@
       border: 1px solid var(--ExcTextField--border);
       border-radius: 0.5rem;
 
+      padding: 0 0.75rem;
+
       &:not(&--readonly) {
         &:hover {
           border-color: var(--ExcTextField--border-hover);
@@ -80,10 +94,6 @@
 
         width: 100%;
 
-        &::placeholder {
-          color: var(--ExcTextField--placeholder);
-        }
-
         &:not(:focus) {
           &:hover {
             background-color: initial;
@@ -105,5 +115,9 @@
         }
       }
     }
+
+    &--hasIcon .ExcTextField__input {
+      padding-left: 2.5rem;
+    }
   }
 }

+ 8 - 2
packages/excalidraw/components/TextField.tsx

@@ -21,7 +21,9 @@ type TextFieldProps = {
   fullWidth?: boolean;
   selectOnRender?: boolean;
 
+  icon?: React.ReactNode;
   label?: string;
+  className?: string;
   placeholder?: string;
   isRedacted?: boolean;
 } & ({ value: string } | { defaultValue: string });
@@ -37,6 +39,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
       selectOnRender,
       onKeyDown,
       isRedacted = false,
+      icon,
+      className,
       ...rest
     },
     ref,
@@ -56,14 +60,16 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
 
     return (
       <div
-        className={clsx("ExcTextField", {
+        className={clsx("ExcTextField", className, {
           "ExcTextField--fullWidth": fullWidth,
+          "ExcTextField--hasIcon": !!icon,
         })}
         onClick={() => {
           innerRef.current?.focus();
         }}
       >
-        <div className="ExcTextField__label">{label}</div>
+        {icon}
+        {label && <div className="ExcTextField__label">{label}</div>}
         <div
           className={clsx("ExcTextField__input", {
             "ExcTextField__input--readonly": readonly,

+ 1 - 0
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
   snapLines: appState.snapLines,
   zenModeEnabled: appState.zenModeEnabled,
   editingTextElement: appState.editingTextElement,
+  searchMatches: appState.searchMatches,
 });
 
 const areEqual = (

+ 8 - 0
packages/excalidraw/components/icons.tsx

@@ -2139,3 +2139,11 @@ export const collapseUpIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const upIcon = createIcon(
+  <g>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M6 15l6 -6l6 6" />
+  </g>,
+  tablerIconProps,
+);

+ 23 - 1
packages/excalidraw/components/main-menu/DefaultItems.tsx

@@ -15,6 +15,7 @@ import {
   LoadIcon,
   MoonIcon,
   save,
+  searchIcon,
   SunIcon,
   TrashIcon,
   usersIcon,
@@ -27,6 +28,7 @@ import {
   actionLoadScene,
   actionSaveToActiveFile,
   actionShortcuts,
+  actionToggleSearchMenu,
   actionToggleTheme,
 } from "../../actions";
 import clsx from "clsx";
@@ -40,7 +42,6 @@ import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemConten
 import { THEME } from "../../constants";
 import type { Theme } from "../../element/types";
 import { trackEvent } from "../../analytics";
-
 import "./DefaultItems.scss";
 
 export const LoadScene = () => {
@@ -145,6 +146,27 @@ export const CommandPalette = (opts?: { className?: string }) => {
 };
 CommandPalette.displayName = "CommandPalette";
 
+export const SearchMenu = (opts?: { className?: string }) => {
+  const { t } = useI18n();
+  const actionManager = useExcalidrawActionManager();
+
+  return (
+    <DropdownMenuItem
+      icon={searchIcon}
+      data-testid="search-menu-button"
+      onSelect={() => {
+        actionManager.executeAction(actionToggleSearchMenu);
+      }}
+      shortcut={getShortcutFromShortcutName("searchMenu")}
+      aria-label={t("search.title")}
+      className={opts?.className}
+    >
+      {t("search.title")}
+    </DropdownMenuItem>
+  );
+};
+SearchMenu.displayName = "SearchMenu";
+
 export const Help = () => {
   const { t } = useI18n();
 

+ 5 - 0
packages/excalidraw/constants.ts

@@ -113,6 +113,7 @@ export const ENV = {
 export const CLASSES = {
   SHAPE_ACTIONS_MENU: "App-menu__left",
   ZOOM_ACTIONS: "zoom-actions",
+  SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
 };
 
 /**
@@ -382,6 +383,10 @@ export const DEFAULT_SIDEBAR = {
   defaultTab: LIBRARY_SIDEBAR_TAB,
 } as const;
 
+export const SEARCH_SIDEBAR = {
+  name: "search",
+};
+
 export const LIBRARY_DISABLED_TYPES = new Set([
   "iframe",
   "embeddable",

+ 3 - 3
packages/excalidraw/css/theme.scss

@@ -144,9 +144,9 @@
   --border-radius-md: 0.375rem;
   --border-radius-lg: 0.5rem;
 
-  --color-surface-high: hsl(244, 100%, 97%);
-  --color-surface-mid: hsl(240 25% 96%);
-  --color-surface-low: hsl(240 25% 94%);
+  --color-surface-high: #f1f0ff;
+  --color-surface-mid: #f2f2f7;
+  --color-surface-low: #ececf4;
   --color-surface-lowest: #ffffff;
   --color-on-surface: #1b1b1f;
   --color-brand-hover: #5753d0;

+ 4 - 3
packages/excalidraw/element/textElement.ts

@@ -284,16 +284,17 @@ export const measureText = (
   text: string,
   font: FontString,
   lineHeight: ExcalidrawTextElement["lineHeight"],
+  forceAdvanceWidth?: true,
 ) => {
-  text = text
+  const _text = text
     .split("\n")
     // replace empty lines with single space because leading/trailing empty
     // lines would be stripped from computation
     .map((x) => x || " ")
     .join("\n");
   const fontSize = parseFloat(font);
-  const height = getTextHeight(text, fontSize, lineHeight);
-  const width = getTextWidth(text, font);
+  const height = getTextHeight(_text, fontSize, lineHeight);
+  const width = getTextWidth(_text, font, forceAdvanceWidth);
   return { width, height };
 };
 

+ 8 - 0
packages/excalidraw/locales/en.json

@@ -162,6 +162,13 @@
     "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.",
     "hint_emptyPrivateLibrary": "Select an item on canvas to add it here."
   },
+  "search": {
+    "title": "Find on canvas",
+    "noMatch": "No matches found...",
+    "singleResult": "result",
+    "multipleResults": "results",
+    "placeholder": "Find text..."
+  },
   "buttons": {
     "clearReset": "Reset the canvas",
     "exportJSON": "Export to file",
@@ -297,6 +304,7 @@
     "shapes": "Shapes"
   },
   "hints": {
+    "dismissSearch": "Escape to dismiss search",
     "canvasPanning": "To move canvas, hold mouse wheel or spacebar while dragging, or use the hand tool",
     "linearElement": "Click to start multiple points, drag for single line",
     "arrowTool": "Click to start multiple points, drag for single line. Press {{arrowShortcut}} again to change arrow type.",

+ 46 - 3
packages/excalidraw/renderer/interactiveScene.ts

@@ -30,8 +30,12 @@ import {
   shouldShowBoundingBox,
 } from "../element/transformHandles";
 import { arrayToMap, throttleRAF } from "../utils";
-import type { InteractiveCanvasAppState } from "../types";
-import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
+import {
+  DEFAULT_TRANSFORM_HANDLE_SPACING,
+  FRAME_STYLE,
+  THEME,
+} from "../constants";
+import { type InteractiveCanvasAppState } from "../types";
 
 import { renderSnaps } from "../renderer/renderSnaps";
 
@@ -952,9 +956,48 @@ const _renderInteractiveScene = ({
     context.restore();
   }
 
+  appState.searchMatches.forEach(({ id, focus, matchedLines }) => {
+    const element = elementsMap.get(id);
+
+    if (element && isTextElement(element)) {
+      const [elementX1, elementY1, , , cx, cy] = getElementAbsoluteCoords(
+        element,
+        elementsMap,
+        true,
+      );
+
+      context.save();
+      if (appState.theme === THEME.LIGHT) {
+        if (focus) {
+          context.fillStyle = "rgba(255, 124, 0, 0.4)";
+        } else {
+          context.fillStyle = "rgba(255, 226, 0, 0.4)";
+        }
+      } else if (focus) {
+        context.fillStyle = "rgba(229, 82, 0, 0.4)";
+      } else {
+        context.fillStyle = "rgba(99, 52, 0, 0.4)";
+      }
+
+      context.translate(appState.scrollX, appState.scrollY);
+      context.translate(cx, cy);
+      context.rotate(element.angle);
+
+      matchedLines.forEach((matchedLine) => {
+        context.fillRect(
+          elementX1 + matchedLine.offsetX - cx,
+          elementY1 + matchedLine.offsetY - cy,
+          matchedLine.width,
+          matchedLine.height,
+        );
+      });
+
+      context.restore();
+    }
+  });
+
   renderSnaps(context, appState);
 
-  // Reset zoom
   context.restore();
 
   renderRemoteCursors({

+ 17 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -866,6 +866,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -1068,6 +1069,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1283,6 +1285,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1613,6 +1616,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1943,6 +1947,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -2158,6 +2163,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -2397,6 +2403,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0_copy": true,
   },
@@ -2699,6 +2706,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -3065,6 +3073,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -3539,6 +3548,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -4185,6 +4196,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -5370,6 +5382,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -6496,6 +6509,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -7431,6 +7445,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -8339,6 +8354,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9235,6 +9251,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },

+ 49 - 0
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -239,6 +239,55 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         Ctrl+Shift+E
       </div>
     </button>
+    <button
+      aria-label="Find on canvas"
+      class="dropdown-menu-item dropdown-menu-item-base"
+      data-testid="search-menu-button"
+      title="Find on canvas"
+    >
+      <div
+        class="dropdown-menu-item__icon"
+      >
+        <svg
+          aria-hidden="true"
+          class=""
+          fill="none"
+          focusable="false"
+          role="img"
+          stroke="currentColor"
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          stroke-width="2"
+          viewBox="0 0 24 24"
+        >
+          <g
+            stroke-width="1.5"
+          >
+            <path
+              d="M0 0h24v24H0z"
+              fill="none"
+              stroke="none"
+            />
+            <path
+              d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"
+            />
+            <path
+              d="M21 21l-6 -6"
+            />
+          </g>
+        </svg>
+      </div>
+      <div
+        class="dropdown-menu-item__text"
+      >
+        Find on canvas
+      </div>
+      <div
+        class="dropdown-menu-item__shortcut"
+      >
+        Ctrl+F
+      </div>
+    </button>
     <button
       aria-label="Help"
       class="dropdown-menu-item dropdown-menu-item-base"

+ 58 - 0
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -80,6 +80,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id166": true,
   },
@@ -681,6 +682,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id161": true,
   },
@@ -1187,6 +1189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -1554,6 +1557,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -1922,6 +1926,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -2185,6 +2190,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id180": true,
   },
@@ -2627,6 +2633,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -2925,6 +2932,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -3208,6 +3216,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -3501,6 +3510,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -3786,6 +3796,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4020,6 +4031,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4278,6 +4290,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4550,6 +4563,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4780,6 +4794,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5010,6 +5025,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5238,6 +5254,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5466,6 +5483,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5723,6 +5741,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -6053,6 +6072,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -6477,6 +6497,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id100": true,
     "id101": true,
@@ -6855,6 +6876,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id105": true,
     "id106": true,
@@ -7170,6 +7192,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id113": true,
   },
@@ -7467,6 +7490,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -7695,6 +7719,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -8049,6 +8074,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -8406,6 +8432,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id127": true,
     "id128": true,
@@ -8806,6 +8833,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -9092,6 +9120,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id124": true,
   },
@@ -9356,6 +9385,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id81": true,
   },
@@ -9619,6 +9649,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id83": true,
   },
@@ -9852,6 +9883,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -10149,6 +10181,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id92": true,
   },
@@ -10488,6 +10521,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -10725,6 +10759,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -11174,6 +11209,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id84": true,
   },
@@ -11427,6 +11463,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -11665,6 +11702,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id14": true,
   },
@@ -11905,6 +11943,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -12308,6 +12347,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   "resizingElement": null,
   "scrollX": -50,
   "scrollY": -50,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -12551,6 +12591,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id78": true,
   },
@@ -12791,6 +12832,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id19": true,
   },
@@ -13033,6 +13075,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -13277,6 +13320,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id8": true,
   },
@@ -13611,6 +13655,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -13779,6 +13824,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
     "id2": true,
@@ -14069,6 +14115,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -14334,6 +14381,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id11": true,
   },
@@ -14609,6 +14657,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -14768,6 +14817,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id55": true,
   },
@@ -15463,6 +15513,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id49": true,
   },
@@ -16082,6 +16133,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id61": true,
   },
@@ -16699,6 +16751,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id62": true,
   },
@@ -17412,6 +17465,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id69": true,
     "id71": true,
@@ -18161,6 +18215,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id39": true,
     "id41": true,
@@ -18634,6 +18689,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id35_copy_copy": true,
     "id36_copy_copy": true,
@@ -19155,6 +19211,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -19610,6 +19667,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   "resizingElement": null,
   "scrollX": 0,
   "scrollY": 0,
+  "searchMatches": [],
   "selectedElementIds": {
     "id27": true,
   },

+ 52 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -84,6 +84,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id2": true,
@@ -495,6 +496,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -893,6 +895,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id7": true,
   },
@@ -1434,6 +1437,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -1636,6 +1640,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id2": true,
@@ -2007,6 +2012,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -2241,6 +2247,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -2419,6 +2426,7 @@ exports[`regression tests > can drag element that covers another element, while
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -2733,6 +2741,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -2977,6 +2986,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -3216,6 +3226,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -3442,6 +3453,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -3694,6 +3706,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -3999,6 +4012,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -4412,6 +4426,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4691,6 +4706,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -4939,6 +4955,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5145,6 +5162,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -5338,6 +5356,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id2": true,
   },
@@ -5719,6 +5738,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -6002,6 +6022,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -6809,6 +6830,7 @@ exports[`regression tests > given a group of selected elements with an element t
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -7134,6 +7156,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -7406,6 +7429,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -7636,6 +7660,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -7867,6 +7892,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -8043,6 +8069,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -8219,6 +8246,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -8395,6 +8423,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -8613,6 +8642,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -8830,6 +8860,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -9020,6 +9051,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9238,6 +9270,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9414,6 +9447,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9631,6 +9665,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -9807,6 +9842,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -9997,6 +10033,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -10177,6 +10214,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -10685,6 +10723,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -10956,6 +10995,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "scrollX": "-6.25000",
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -11080,6 +11120,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -11276,6 +11317,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -11584,6 +11626,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -11993,6 +12036,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
     "id1": true,
@@ -12600,6 +12644,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "scrollX": 60,
   "scrollY": 60,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -12724,6 +12769,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -13305,6 +13351,7 @@ exports[`regression tests > switches from group of selected elements to another
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -13638,6 +13685,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id0": true,
   },
@@ -13897,6 +13945,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "scrollX": 20,
   "scrollY": "-18.53553",
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -14019,6 +14068,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {
     "id1": true,
   },
@@ -14394,6 +14444,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},
@@ -14519,6 +14570,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},

+ 13 - 7
packages/excalidraw/tests/helpers/ui.ts

@@ -69,8 +69,11 @@ export class Keyboard {
     }
   };
 
-  static keyDown = (key: string) => {
-    fireEvent.keyDown(document, {
+  static keyDown = (
+    key: string,
+    target: HTMLElement | Document | Window = document,
+  ) => {
+    fireEvent.keyDown(target, {
       key,
       ctrlKey,
       shiftKey,
@@ -78,8 +81,11 @@ export class Keyboard {
     });
   };
 
-  static keyUp = (key: string) => {
-    fireEvent.keyUp(document, {
+  static keyUp = (
+    key: string,
+    target: HTMLElement | Document | Window = document,
+  ) => {
+    fireEvent.keyUp(target, {
       key,
       ctrlKey,
       shiftKey,
@@ -87,9 +93,9 @@ export class Keyboard {
     });
   };
 
-  static keyPress = (key: string) => {
-    Keyboard.keyDown(key);
-    Keyboard.keyUp(key);
+  static keyPress = (key: string, target?: HTMLElement | Document | Window) => {
+    Keyboard.keyDown(key, target);
+    Keyboard.keyUp(key, target);
   };
 
   static codeDown = (code: string) => {

+ 1 - 1
packages/excalidraw/tests/queries/dom.ts

@@ -11,7 +11,7 @@ export const getTextEditor = async (selector: string, waitForEditor = true) => {
 };
 
 export const updateTextEditor = (
-  editor: HTMLTextAreaElement,
+  editor: HTMLTextAreaElement | HTMLInputElement,
   value: string,
 ) => {
   fireEvent.change(editor, { target: { value } });

+ 143 - 0
packages/excalidraw/tests/search.test.tsx

@@ -0,0 +1,143 @@
+import React from "react";
+import { render, waitFor } from "./test-utils";
+import { Excalidraw, mutateElement } from "../index";
+import { CLASSES, SEARCH_SIDEBAR } from "../constants";
+import { Keyboard } from "./helpers/ui";
+import { KEYS } from "../keys";
+import { updateTextEditor } from "./queries/dom";
+import { API } from "./helpers/api";
+import type { ExcalidrawTextElement } from "../element/types";
+
+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 />);
+    h.setState({
+      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(SEARCH_SIDEBAR.name);
+
+    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`,
+      );
+
+    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(SEARCH_SIDEBAR.name);
+
+    const searchInput = await querySearchInput();
+
+    expect(searchInput.matches(":focus")).toBe(true);
+
+    updateTextEditor(searchInput, "test");
+
+    await waitFor(() => {
+      expect(h.app.state.searchMatches.length).toBe(2);
+      expect(h.app.state.searchMatches[0].focus).toBe(true);
+    });
+
+    Keyboard.keyPress(KEYS.ENTER, searchInput);
+    expect(h.app.state.searchMatches[0].focus).toBe(false);
+    expect(h.app.state.searchMatches[1].focus).toBe(true);
+
+    Keyboard.keyPress(KEYS.ENTER, searchInput);
+    expect(h.app.state.searchMatches[0].focus).toBe(true);
+    expect(h.app.state.searchMatches[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: "",
+      }),
+    ]);
+
+    mutateElement(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(SEARCH_SIDEBAR.name);
+
+    const searchInput = await querySearchInput();
+
+    expect(searchInput.matches(":focus")).toBe(true);
+
+    updateTextEditor(searchInput, "test");
+
+    await waitFor(() => {
+      expect(h.app.state.searchMatches.length).toBe(1);
+      expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(4);
+    });
+
+    updateTextEditor(searchInput, "ext spli");
+
+    await waitFor(() => {
+      expect(h.app.state.searchMatches.length).toBe(1);
+      expect(h.app.state.searchMatches[0]?.matchedLines?.length).toBe(6);
+    });
+  });
+});

+ 17 - 0
packages/excalidraw/types.ts

@@ -198,6 +198,8 @@ export type InteractiveCanvasAppState = Readonly<
     snapLines: AppState["snapLines"];
     zenModeEnabled: AppState["zenModeEnabled"];
     editingTextElement: AppState["editingTextElement"];
+    // Search matches
+    searchMatches: AppState["searchMatches"];
   }
 >;
 
@@ -384,8 +386,20 @@ export interface AppState {
   userToFollow: UserToFollow | null;
   /** the socket ids of the users following the current user */
   followedBy: Set<SocketId>;
+  searchMatches: readonly SearchMatch[];
 }
 
+type SearchMatch = {
+  id: string;
+  focus: boolean;
+  matchedLines: {
+    offsetX: number;
+    offsetY: number;
+    width: number;
+    height: number;
+  }[];
+};
+
 export type UIAppState = Omit<
   AppState,
   | "suggestedBindings"
@@ -642,6 +656,9 @@ export type AppClassProperties = {
   getEffectiveGridSize: App["getEffectiveGridSize"];
   setPlugins: App["setPlugins"];
   plugins: App["plugins"];
+  getEditorUIOffsets: App["getEditorUIOffsets"];
+  visibleElements: App["visibleElements"];
+  excalidrawContainerValue: App["excalidrawContainerValue"];
 };
 
 export type PointerDownState = Readonly<{

+ 1 - 0
packages/utils/__snapshots__/export.test.ts.snap

@@ -81,6 +81,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "scrollX": 0,
   "scrollY": 0,
   "scrolledOutside": false,
+  "searchMatches": [],
   "selectedElementIds": {},
   "selectedElementsAreBeingDragged": false,
   "selectedGroupIds": {},