From 6959a363f0ba8d0e8b05442f4c8f3cc4a2e00ca6 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 9 Sep 2024 23:12:07 +0800 Subject: [PATCH] feat: canvas search (#8438) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .eslintignore | 1 + excalidraw-app/components/AppMainMenu.tsx | 1 + .../actions/actionToggleSearchMenu.ts | 51 ++ packages/excalidraw/actions/index.ts | 2 + packages/excalidraw/actions/shortcuts.ts | 4 +- packages/excalidraw/actions/types.ts | 6 +- packages/excalidraw/appState.ts | 2 + packages/excalidraw/components/App.tsx | 28 +- .../CommandPalette/CommandPalette.tsx | 15 +- .../excalidraw/components/DefaultSidebar.tsx | 6 +- packages/excalidraw/components/HelpDialog.tsx | 4 + packages/excalidraw/components/HintViewer.tsx | 8 + packages/excalidraw/components/LayerUI.tsx | 28 +- .../excalidraw/components/SearchMenu.scss | 110 +++ packages/excalidraw/components/SearchMenu.tsx | 671 ++++++++++++++++++ .../excalidraw/components/SearchSidebar.tsx | 29 + packages/excalidraw/components/TextField.scss | 28 +- packages/excalidraw/components/TextField.tsx | 10 +- .../components/canvases/InteractiveCanvas.tsx | 1 + packages/excalidraw/components/icons.tsx | 8 + .../components/main-menu/DefaultItems.tsx | 24 +- packages/excalidraw/constants.ts | 5 + packages/excalidraw/css/theme.scss | 6 +- packages/excalidraw/element/textElement.ts | 7 +- packages/excalidraw/locales/en.json | 8 + .../excalidraw/renderer/interactiveScene.ts | 49 +- .../__snapshots__/contextmenu.test.tsx.snap | 17 + .../__snapshots__/excalidraw.test.tsx.snap | 49 ++ .../tests/__snapshots__/history.test.tsx.snap | 58 ++ .../regressionTests.test.tsx.snap | 52 ++ packages/excalidraw/tests/helpers/ui.ts | 20 +- packages/excalidraw/tests/queries/dom.ts | 2 +- packages/excalidraw/tests/search.test.tsx | 143 ++++ packages/excalidraw/types.ts | 17 + .../utils/__snapshots__/export.test.ts.snap | 1 + 35 files changed, 1424 insertions(+), 47 deletions(-) create mode 100644 packages/excalidraw/actions/actionToggleSearchMenu.ts create mode 100644 packages/excalidraw/components/SearchMenu.scss create mode 100644 packages/excalidraw/components/SearchMenu.tsx create mode 100644 packages/excalidraw/components/SearchSidebar.tsx create mode 100644 packages/excalidraw/tests/search.test.tsx diff --git a/.eslintignore b/.eslintignore index 8578fb7d4..8b4f458de 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,4 @@ public/workbox packages/excalidraw/types examples/**/public dev-dist +coverage diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 04bddedef..fd8d779a7 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -31,6 +31,7 @@ export const AppMainMenu: React.FC<{ /> )} + diff --git a/packages/excalidraw/actions/actionToggleSearchMenu.ts b/packages/excalidraw/actions/actionToggleSearchMenu.ts new file mode 100644 index 000000000..6072fd30c --- /dev/null +++ b/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( + `.${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, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index 092060425..eff5de297 100644 --- a/packages/excalidraw/actions/index.ts +++ b/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"; diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index a5c3bad66..025d91037 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -51,7 +51,8 @@ export type ShortcutName = > | "saveScene" | "imageExport" - | "commandPalette"; + | "commandPalette" + | "searchMenu"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -112,6 +113,7 @@ const shortcutMap: Record = { saveFileToDisk: [getShortcutKey("CtrlOrCmd+S")], saveToActiveFile: [getShortcutKey("CtrlOrCmd+S")], toggleShortcuts: [getShortcutKey("?")], + searchMenu: [getShortcutKey("CtrlOrCmd+F")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 2d0275bb3..15364d21f 100644 --- a/packages/excalidraw/actions/types.ts +++ b/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, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index faad34057..cb80c6cd8 100644 --- a/packages/excalidraw/appState.ts +++ b/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 = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 183df35cc..e067bba7d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/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 { 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 { 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 { 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 { 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 { }, ); - private getEditorUIOffsets = (): { + public getEditorUIOffsets = (): { top: number; right: number; bottom: number; @@ -5973,6 +5977,16 @@ class App extends React.Component { 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 { } 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(); } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 36c9a9a68..e732acfb5 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/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"], diff --git a/packages/excalidraw/components/DefaultSidebar.tsx b/packages/excalidraw/components/DefaultSidebar.tsx index 78b03007f..5cd588933 100644 --- a/packages/excalidraw/components/DefaultSidebar.tsx +++ b/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 ( void }) => { label={t("stats.fullTitle")} shortcuts={[getShortcutKey("Alt+/")]} /> + )} + @@ -362,16 +365,21 @@ const LayerUI = ({ const renderSidebars = () => { return ( - { - trackEvent( - "sidebar", - `toggleDock (${docked ? "dock" : "undock"})`, - `(${device.editor.isMobile ? "mobile" : "desktop"})`, - ); - }} - /> + <> + {appState.openSidebar?.name === SEARCH_SIDEBAR.name && ( + + )} + { + trackEvent( + "sidebar", + `toggleDock (${docked ? "dock" : "undock"})`, + `(${device.editor.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> + ); }; diff --git a/packages/excalidraw/components/SearchMenu.scss b/packages/excalidraw/components/SearchMenu.scss new file mode 100644 index 000000000..ae6bb5647 --- /dev/null +++ b/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); + } + } +} diff --git a/packages/excalidraw/components/SearchMenu.tsx b/packages/excalidraw/components/SearchMenu.tsx new file mode 100644 index 000000000..f05660a52 --- /dev/null +++ b/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(""); +export const searchItemInFocusAtom = atom(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(null); + + const [keyword, setKeyword] = useAtom(searchKeywordAtom, jotaiScope); + const [searchMatches, setSearchMatches] = useState({ + nonce: null, + items: [], + }); + const searchedKeywordRef = useRef(); + const lastSceneNonceRef = useRef(); + + 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 + */ + useEffect(() => { + searchInputRef.current?.focus(); + }, []); + + const matchCount = `${searchMatches.items.length} ${ + searchMatches.items.length === 1 + ? t("search.singleResult") + : t("search.multipleResults") + }`; + + return ( +
+
+ { + setKeyword(value); + }} + /> +
+ +
+ {searchMatches.items.length > 0 && ( + <> + {focusIndex !== null && focusIndex > -1 ? ( +
+ {focusIndex + 1} / {matchCount} +
+ ) : ( +
{matchCount}
+ )} +
+ + +
+ + )} + + {searchMatches.items.length === 0 && + keyword && + searchedKeywordRef.current && ( +
{t("search.noMatch")}
+ )} +
+ + +
+ ); +}; + +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 ( +
{ + if (props.highlighted) { + ref?.scrollIntoView({ behavior: "auto", block: "nearest" }); + } + }} + > +
+ {preview.flatMap((text, idx) => ( + {idx === 2 ? {text} : text} + ))} +
+
+ ); +}; + +interface MatchListProps { + matches: SearchMatches; + onItemClick: (index: number) => void; + focusIndex: number | null; + trimmedKeyword: string; +} + +const MatchListBase = (props: MatchListProps) => { + return ( +
+ {props.matches.items.map((searchMatch, index) => ( + props.onItemClick(index)} + /> + ))} +
+ ); +}; + +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, +); diff --git a/packages/excalidraw/components/SearchSidebar.tsx b/packages/excalidraw/components/SearchSidebar.tsx new file mode 100644 index 000000000..7cb93ac5f --- /dev/null +++ b/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 ( + + + +
+ {t("search.title")} +
+
+ +
+
+ ); +}; diff --git a/packages/excalidraw/components/TextField.scss b/packages/excalidraw/components/TextField.scss index 952c97592..c46cd2fe8 100644 --- a/packages/excalidraw/components/TextField.scss +++ b/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; + } } } diff --git a/packages/excalidraw/components/TextField.tsx b/packages/excalidraw/components/TextField.tsx index 463ea2c2d..e1d346111 100644 --- a/packages/excalidraw/components/TextField.tsx +++ b/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( selectOnRender, onKeyDown, isRedacted = false, + icon, + className, ...rest }, ref, @@ -56,14 +60,16 @@ export const TextField = forwardRef( return (
{ innerRef.current?.focus(); }} > -
{label}
+ {icon} + {label &&
{label}
}
, tablerIconProps, ); + +export const upIcon = createIcon( + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 26ef26000..bb3059db5 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/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 ( + { + actionManager.executeAction(actionToggleSearchMenu); + }} + shortcut={getShortcutFromShortcutName("searchMenu")} + aria-label={t("search.title")} + className={opts?.className} + > + {t("search.title")} + + ); +}; +SearchMenu.displayName = "SearchMenu"; + export const Help = () => { const { t } = useI18n(); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 9601807f6..31982d4fb 100644 --- a/packages/excalidraw/constants.ts +++ b/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", diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 0ed6a7544..69c28afda 100644 --- a/packages/excalidraw/css/theme.scss +++ b/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; diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 9fb4766b6..9abebc356 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/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 }; }; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ebf9ff872..ff1fa2026 100644 --- a/packages/excalidraw/locales/en.json +++ b/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.", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 5a27a3312..0d03b0f5a 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/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({ diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 7f9904a4d..3a5e14065 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/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, }, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index 2994cfc3e..e5e431dfc 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -239,6 +239,55 @@ exports[` > Test UIOptions prop > Test canvasActions > should rende Ctrl+Shift+E
+