mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
5a11c70714
commit
6959a363f0
35 changed files with 1424 additions and 47 deletions
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
packages/excalidraw/components/SearchMenu.scss
Normal file
110
packages/excalidraw/components/SearchMenu.scss
Normal file
|
@ -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
packages/excalidraw/components/SearchMenu.tsx
Normal file
671
packages/excalidraw/components/SearchMenu.tsx
Normal file
|
@ -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
packages/excalidraw/components/SearchSidebar.tsx
Normal file
29
packages/excalidraw/components/SearchSidebar.tsx
Normal file
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -203,6 +203,7 @@ const getRelevantAppStateProps = (
|
|||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingTextElement: appState.editingTextElement,
|
||||
searchMatches: appState.searchMatches,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue