35 changed files with 1424 additions and 47 deletions
@ -0,0 +1,51 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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