feat: canvas search (#8438)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>pull/8483/head
parent
5a11c70714
commit
6959a363f0
@ -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,
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue