fix: Fix issue where searching across formatted text would not work in Super note (#2897) (skip e2e)

This commit is contained in:
Aman Harwara
2025-04-25 23:57:14 +05:30
committed by GitHub
parent 23f5ac1c19
commit bdf6be38d2
21 changed files with 1079 additions and 683 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -68,7 +68,7 @@
"npm-check-updates": "^16.10.17",
"prettier": "3.0.0",
"sass-loader": "^13.3.2",
"typescript": "5.2.2",
"typescript": "5.8.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",

View File

@@ -27,7 +27,7 @@
"babel-loader": "^8.2.3",
"html-webpack-plugin": "^5.5.0",
"ts-loader": "^9.2.6",
"typescript": "^4.0.5",
"typescript": "*",
"typescript-eslint": "0.0.1-alpha.0",
"webpack": "*",
"webpack-cli": "*",

View File

@@ -2,6 +2,9 @@
* @jest-environment jsdom
*/
// @ts-expect-error CSS is not defined in jsdom env
global.CSS = {}
import { WebApplication } from '@/Application/WebApplication'
import { NotesController } from '@/Controllers/NotesController/NotesController'
import {

View File

@@ -19,6 +19,7 @@ const StyledTooltip = ({
type = 'label',
side,
documentElement,
closeOnClick = true,
...props
}: {
children: ReactNode
@@ -30,6 +31,7 @@ const StyledTooltip = ({
type?: TooltipStoreProps['type']
side?: PopoverSide
documentElement?: HTMLElement
closeOnClick?: boolean
} & Partial<TooltipOptions>) => {
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
@@ -69,7 +71,11 @@ const StyledTooltip = ({
const clickProps = isMobile
? {}
: {
onClick: () => tooltip.hide(),
onClick: () => {
if (closeOnClick) {
tooltip.hide()
}
},
}
useEffect(() => {

View File

@@ -29,7 +29,6 @@ import ToolbarPlugin from './Plugins/ToolbarPlugin/ToolbarPlugin'
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin'
import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin'
@@ -134,9 +133,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<RemoveBrokenTablesPlugin />
<RemoteImagePlugin />
<CodeOptionsPlugin />
<SuperSearchContextProvider>
<SearchPlugin />
</SuperSearchContextProvider>
<SearchPlugin />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />

View File

@@ -40,3 +40,23 @@
user-select: none;
}
}
::highlight(search-results) {
background-color: var(--sn-stylekit-info-color);
background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 60%);
color: var(--text-norm);
}
// has to be defined separately, otherwise browsers which don't support ::highlight syntax
// will throw out the whole selector
.search-highlight {
background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 60%);
}
.active-search-highlight {
background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 30%);
}
::highlight(active-search-result) {
background-color: var(--sn-stylekit-info-color);
color: var(--text-norm);
}

View File

@@ -1,130 +0,0 @@
import { createContext, ReactNode, useCallback, useContext, useMemo, useReducer, useRef } from 'react'
import { SuperSearchContextAction, SuperSearchContextState, SuperSearchReplaceEvent } from './Types'
type SuperSearchContextData = SuperSearchContextState & {
dispatch: React.Dispatch<SuperSearchContextAction>
addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void
dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void
}
const SuperSearchContext = createContext<SuperSearchContextData | undefined>(undefined)
export const useSuperSearchContext = () => {
const context = useContext(SuperSearchContext)
if (!context) {
throw new Error('useSuperSearchContext must be used within a SuperSearchContextProvider')
}
return context
}
const initialState: SuperSearchContextState = {
query: '',
results: [],
currentResultIndex: -1,
isCaseSensitive: false,
isSearchActive: false,
isReplaceMode: false,
}
const searchContextReducer = (
state: SuperSearchContextState,
action: SuperSearchContextAction,
): SuperSearchContextState => {
switch (action.type) {
case 'set-query':
return {
...state,
query: action.query,
}
case 'set-results':
return {
...state,
results: action.results,
currentResultIndex: action.results.length > 0 ? 0 : -1,
}
case 'clear-results':
return {
...state,
results: [],
currentResultIndex: -1,
}
case 'set-current-result-index':
return {
...state,
currentResultIndex: action.index,
}
case 'toggle-search':
return {
...initialState,
isSearchActive: !state.isSearchActive,
}
case 'toggle-case-sensitive':
return {
...state,
isCaseSensitive: !state.isCaseSensitive,
}
case 'toggle-replace-mode': {
const toggledValue = !state.isReplaceMode
return {
...state,
isSearchActive: toggledValue && !state.isSearchActive ? true : state.isSearchActive,
isReplaceMode: toggledValue,
}
}
case 'go-to-next-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex + 1 < state.results.length
? state.currentResultIndex + 1
: 0,
}
case 'go-to-previous-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex - 1 >= 0
? state.currentResultIndex - 1
: state.results.length - 1,
}
case 'reset-search':
return { ...initialState }
}
}
export const SuperSearchContextProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(searchContextReducer, initialState)
const replaceEventListeners = useRef(new Set<(type: SuperSearchReplaceEvent) => void>())
const addReplaceEventListener = useCallback((listener: (type: SuperSearchReplaceEvent) => void) => {
replaceEventListeners.current.add(listener)
return () => {
replaceEventListeners.current.delete(listener)
}
}, [])
const dispatchReplaceEvent = useCallback((type: SuperSearchReplaceEvent) => {
replaceEventListeners.current.forEach((listener) => listener(type))
}, [])
const value = useMemo(
() => ({
...state,
dispatch,
addReplaceEventListener,
dispatchReplaceEvent,
}),
[addReplaceEventListener, dispatchReplaceEvent, state],
)
return <SuperSearchContext.Provider value={value}>{children}</SuperSearchContext.Provider>
}

View File

@@ -1,240 +0,0 @@
import Button from '@/Components/Button/Button'
import { useCommandService } from '@/Components/CommandProvider'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import { ArrowDownIcon, ArrowUpIcon, CloseIcon, ArrowRightIcon } from '@standardnotes/icons'
import {
KeyboardKey,
keyboardStringForShortcut,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
} from '@standardnotes/ui-services'
import { classNames } from '@standardnotes/utils'
import { useCallback, useMemo, useState } from 'react'
import { useSuperSearchContext } from './Context'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
const [editor] = useLexicalComposerContext()
const { query, results, currentResultIndex, isCaseSensitive, isReplaceMode, dispatch, dispatchReplaceEvent } =
useSuperSearchContext()
const [replaceQuery, setReplaceQuery] = useState('')
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
}
}, [])
const [isMounted, setElement] = useLifecycleAnimation({
open,
enter: TranslateFromTopAnimation,
exit: TranslateToTopAnimation,
})
const commandService = useCommandService()
const searchToggleShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
[commandService],
)
const toggleReplaceShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
[commandService],
)
const caseSensitivityShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
[commandService],
)
if (!isMounted) {
return null
}
return (
<div
className={classNames(
'absolute left-2 right-6 top-2 z-10 flex select-none rounded border border-border bg-default md:left-auto',
editor.isEditable() ? 'md:top-13' : 'md:top-3',
)}
ref={setElement}
>
{editor.isEditable() && (
<button
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
onClick={() => {
dispatch({ type: 'toggle-replace-mode' })
}}
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
>
{isReplaceMode ? (
<ArrowDownIcon className="h-4 w-4 fill-text" />
) : (
<ArrowRightIcon className="h-4 w-4 fill-text" />
)}
</button>
)}
<div
className="flex flex-col gap-2 px-2 py-2"
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
closeDialog()
}
}}
>
<div className="flex items-center gap-2">
<DecoratedInput
placeholder="Search"
className={{
container: classNames('flex-grow !text-[length:inherit]', !query.length && '!py-1'),
right: '!py-1',
}}
value={query}
onChange={(query) => {
dispatch({
type: 'set-query',
query,
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && results.length) {
if (event.shiftKey) {
dispatch({
type: 'go-to-previous-result',
})
return
}
dispatch({
type: 'go-to-next-result',
})
}
}}
ref={focusOnMount}
right={[
<div className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right">
{query.length > 0 && (
<>
{currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null}
{results.length}
</>
)}
</div>,
]}
/>
<label
className={classNames(
'relative flex items-center rounded border px-1.5 py-1 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
)}
title={`Case sensitive (${caseSensitivityShortcut})`}
>
<input
type="checkbox"
className="absolute left-0 top-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
checked={isCaseSensitive}
onChange={() => {
dispatch({
type: 'toggle-case-sensitive',
})
}}
/>
<span aria-hidden>Aa</span>
<span className="sr-only">Case sensitive</span>
</label>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-previous-result',
})
}}
disabled={results.length < 1}
title="Previous result (Shift + Enter)"
>
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-next-result',
})
}}
disabled={results.length < 1}
title="Next result (Enter)"
>
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
onClick={() => {
closeDialog()
}}
title={`Close (${searchToggleShortcut})`}
>
<CloseIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
{isReplaceMode && (
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
<input
type="text"
placeholder="Replace"
onChange={(e) => {
setReplaceQuery(e.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && replaceQuery && results.length) {
if (event.ctrlKey && event.altKey) {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
event.preventDefault()
return
}
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
event.preventDefault()
}
}}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
/>
<Button
small
onClick={() => {
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace (Ctrl + Enter)"
>
Replace
</Button>
<Button
small
onClick={() => {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace all (Ctrl + Alt + Enter)"
>
Replace all
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
// CSS Custom Highlight API has been supported on Chrome & Safari for at least 2 years
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { ForwardedRef, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
import { debounce, getScrollParent } from '../../../../Utils'
// now, but its still Nightly-only on Firefox desktop and not supported at all on Firefox Android
export const canUseCSSHiglights = !!('highlights' in CSS)
export interface SearchHighlightRendererMethods {
setActiveHighlight(range: Range): void
highlightMultipleRanges(ranges: Range[]): void
clearHighlights(): void
}
export const SearchHighlightRenderer = forwardRef(
(
{
shouldHighlightAll,
}: {
shouldHighlightAll: boolean
},
ref: ForwardedRef<SearchHighlightRendererMethods>,
) => {
const [editor] = useLexicalComposerContext()
const rootElement = editor.getRootElement()
const rootElementRect = useMemo(() => {
return rootElement?.getBoundingClientRect()
}, [rootElement])
const [activeHighlightRange, setActiveHighlightRange] = useState<Range>()
const [activeHighlightRect, setActiveHighlightRect] = useState<DOMRect>()
const [rangesToHighlight, setRangesToHighlight] = useState<Range[]>([])
const [rangeRects, setRangeRects] = useState<DOMRect[]>([])
const isBoundingClientRectVisible = useCallback(
(rect: DOMRect) => {
if (!rootElementRect) {
return false
}
const rangeTop = rect.top
const rangeBottom = rect.bottom
const isRangeFullyHidden = rangeBottom < rootElementRect.top || rangeTop > rootElementRect.bottom
return !isRangeFullyHidden
},
[rootElementRect],
)
const getBoundingClientRectForRangeIfVisible = useCallback(
(range: Range) => {
const rect = range.getBoundingClientRect()
if (isBoundingClientRectVisible(rect)) {
return rect
}
return undefined
},
[isBoundingClientRectVisible],
)
const getVisibleRectsFromRanges = useCallback(
(ranges: Range[]) => {
const rects: DOMRect[] = []
if (!rootElementRect) {
return rects
}
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (!range) {
continue
}
const rangeBoundingRect = range.getBoundingClientRect()
if (!isBoundingClientRectVisible(rangeBoundingRect)) {
continue
}
rects.push(rangeBoundingRect)
}
return rects
},
[isBoundingClientRectVisible, rootElementRect],
)
useImperativeHandle(
ref,
() => {
return {
setActiveHighlight: (range: Range) => {
if (canUseCSSHiglights) {
CSS.highlights.set('active-search-result', new Highlight(range))
return
}
setActiveHighlightRange(range)
setActiveHighlightRect(getBoundingClientRectForRangeIfVisible(range))
},
highlightMultipleRanges: (ranges: Range[]) => {
if (canUseCSSHiglights) {
const searchResultsHighlight = new Highlight()
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i]
if (!range) {
continue
}
searchResultsHighlight.add(range)
}
CSS.highlights.set('search-results', searchResultsHighlight)
return
}
setRangesToHighlight(ranges)
},
clearHighlights: () => {
if (canUseCSSHiglights) {
CSS.highlights.clear()
return
}
setRangesToHighlight([])
setRangeRects([])
setActiveHighlightRange(undefined)
setActiveHighlightRect(undefined)
},
}
},
[getBoundingClientRectForRangeIfVisible],
)
useEffect(() => {
if (shouldHighlightAll && !canUseCSSHiglights) {
setRangeRects(getVisibleRectsFromRanges(rangesToHighlight))
} else {
setRangeRects([])
}
}, [getVisibleRectsFromRanges, rangesToHighlight, shouldHighlightAll])
useEffect(() => {
if (canUseCSSHiglights) {
return
}
const rootElementScrollParent = getScrollParent(editor.getRootElement())
if (!rootElementScrollParent) {
return
}
const scrollListener = debounce(() => {
if (activeHighlightRange) {
setActiveHighlightRect(getBoundingClientRectForRangeIfVisible(activeHighlightRange))
}
if (shouldHighlightAll) {
setRangeRects(getVisibleRectsFromRanges(rangesToHighlight))
}
}, 16)
rootElementScrollParent.addEventListener('scroll', scrollListener)
return () => {
rootElementScrollParent.removeEventListener('scroll', scrollListener)
}
}, [
activeHighlightRange,
editor,
getBoundingClientRectForRangeIfVisible,
getVisibleRectsFromRanges,
rangesToHighlight,
shouldHighlightAll,
])
if (canUseCSSHiglights || !rootElementRect) {
return null
}
return (
<div className="pointer-events-none absolute left-0 top-0 h-full w-full">
{activeHighlightRect && (
<div
className="active-search-highlight fixed z-[1000]"
style={{
transform: `translate(${activeHighlightRect.left - rootElementRect.left}px, ${
activeHighlightRect.top - rootElementRect.top
}px)`,
width: `${activeHighlightRect.width}px`,
height: `${activeHighlightRect.height}px`,
}}
/>
)}
{rangeRects.map((rect, index) => (
<div
key={index}
className="search-highlight fixed z-[1000]"
style={{
transform: `translate(${rect.left - rootElementRect.left}px, ${rect.top - rootElementRect.top}px)`,
width: `${rect.width}px`,
height: `${rect.height}px`,
}}
/>
))}
</div>
)
},
)

View File

@@ -1,31 +1,113 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getNearestNodeFromDOMNode, TextNode, $createRangeSelection, $setSelection, $isTextNode } from 'lexical'
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import { createSearchHighlightElement } from './createSearchHighlightElement'
import { useSuperSearchContext } from './Context'
import { SearchDialog } from './SearchDialog'
import { getAllTextNodesInElement } from './getAllTextNodesInElement'
import { SuperSearchResult } from './Types'
import { debounce } from '@standardnotes/utils'
import { useApplication } from '@/Components/ApplicationProvider'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useApplication } from '../../../ApplicationProvider'
import {
SUPER_TOGGLE_SEARCH,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_NEXT_RESULT,
SUPER_SEARCH_PREVIOUS_RESULT,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
KeyboardKey,
keyboardStringForShortcut,
} from '@standardnotes/ui-services'
import { useStateRef } from '@/Hooks/useStateRef'
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '../../../../Constants/AnimationConfigs'
import { useLifecycleAnimation } from '../../../../Hooks/useLifecycleAnimation'
import { classNames, debounce } from '@standardnotes/utils'
import DecoratedInput from '../../../Input/DecoratedInput'
import { searchInElement } from './searchInElement'
import { useCommandService } from '../../../CommandProvider'
import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons'
import Button from '../../../Button/Button'
import { canUseCSSHiglights, SearchHighlightRenderer, SearchHighlightRendererMethods } from './SearchHighlightRenderer'
import { useStateRef } from '../../../../Hooks/useStateRef'
import { createPortal } from 'react-dom'
import { $createRangeSelection, $getSelection, $setSelection } from 'lexical'
import StyledTooltip from '../../../StyledTooltip/StyledTooltip'
import Icon from '../../../Icon/Icon'
export const SearchPlugin = () => {
export function SearchPlugin() {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
useSuperSearchContext()
const [isSearchActive, setIsSearchActive] = useState(false)
const [query, setQuery] = useState('')
const queryRef = useStateRef(query)
const currentResultIndexRef = useStateRef(currentResultIndex)
const [results, setResults] = useState<Range[]>([])
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
const resultsRef = useStateRef(results)
const toggleCaseSensitivity = useCallback(() => setIsCaseSensitive((sensitive) => !sensitive), [])
const [isReplaceMode, setIsReplaceMode] = useState(false)
const toggleReplaceMode = useCallback(() => setIsReplaceMode((enabled) => !enabled), [])
const [replaceQuery, setReplaceQuery] = useState('')
const highlightRendererRef = useRef<SearchHighlightRendererMethods>(null)
const [currentResultIndex, setCurrentResultIndex] = useState(-1)
const highlightAndScrollResultIntoView = useCallback(
(index: number) => {
const result = results[index]
if (!result) {
return
}
highlightRendererRef.current?.setActiveHighlight(result)
result.startContainer.parentElement?.scrollIntoView({
block: 'center',
})
},
[results],
)
const goToNextResult = useCallback(() => {
let next = currentResultIndex + 1
if (next >= results.length) {
next = 0
}
highlightAndScrollResultIntoView(next)
setCurrentResultIndex(next)
}, [currentResultIndex, highlightAndScrollResultIntoView, results.length])
const goToPrevResult = useCallback(() => {
let prev = currentResultIndex - 1
if (prev < 0) {
prev = results.length - 1
}
highlightAndScrollResultIntoView(prev)
setCurrentResultIndex(prev)
}, [currentResultIndex, highlightAndScrollResultIntoView, results.length])
const selectCurrentResult = useCallback(() => {
if (results.length === 0) {
return
}
const result = results[currentResultIndex]
if (!result) {
return
}
editor.update(() => {
const rangeSelection = $createRangeSelection()
rangeSelection.applyDOMRange(result)
$setSelection(rangeSelection)
})
}, [currentResultIndex, editor, results])
const [shouldHighlightAll, setShouldHighlightAll] = useState(canUseCSSHiglights)
const closeDialog = useCallback(() => {
selectCurrentResult()
setIsSearchActive(false)
setQuery('')
setResults([])
setIsCaseSensitive(false)
setIsReplaceMode(false)
setReplaceQuery('')
setShouldHighlightAll(canUseCSSHiglights)
editor.update(() => {
if ($getSelection() !== null) {
editor.focus()
}
})
}, [editor, selectCurrentResult])
useEffect(() => {
return application.keyboardService.addCommandHandlers([
@@ -36,7 +118,7 @@ export const SearchPlugin = () => {
onKeyDown: (event) => {
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-search' })
setIsSearchActive((active) => !active)
},
},
{
@@ -49,15 +131,13 @@ export const SearchPlugin = () => {
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-replace-mode' })
toggleReplaceMode()
},
},
{
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
onKeyDown() {
dispatch({
type: 'toggle-case-sensitive',
})
toggleCaseSensitivity()
},
},
{
@@ -67,9 +147,7 @@ export const SearchPlugin = () => {
onKeyDown(event) {
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-next-result',
})
goToNextResult()
},
},
{
@@ -79,92 +157,42 @@ export const SearchPlugin = () => {
onKeyDown(event) {
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-previous-result',
})
goToPrevResult()
},
},
])
}, [application.keyboardService, dispatch, editor])
}, [application.keyboardService, editor, goToNextResult, goToPrevResult, toggleCaseSensitivity, toggleReplaceMode])
const handleSearch = useCallback(
const searchQueryAndHighlight = useCallback(
(query: string, isCaseSensitive: boolean) => {
const currentHighlights = document.querySelectorAll('.search-highlight')
for (const element of currentHighlights) {
element.remove()
}
if (!query) {
dispatch({ type: 'clear-results' })
const highlightRenderer = highlightRendererRef.current
const rootElement = editor.getRootElement()
if (!rootElement || !query) {
highlightRenderer?.clearHighlights()
return
}
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
if (!rootElement) {
return
}
const textNodes = getAllTextNodesInElement(rootElement)
const results: SuperSearchResult[] = []
for (const node of textNodes) {
const text = node.textContent || ''
const indices: number[] = []
let index = -1
const textWithCase = isCaseSensitive ? text : text.toLowerCase()
const queryWithCase = isCaseSensitive ? query : query.toLowerCase()
while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) {
indices.push(index)
}
for (const index of indices) {
const startIndex = index
const endIndex = startIndex + query.length
results.push({
node,
startIndex,
endIndex,
})
}
}
dispatch({
type: 'set-results',
results,
})
})
highlightRenderer?.clearHighlights()
const ranges = searchInElement(rootElement, query, isCaseSensitive)
setResults(ranges)
highlightRenderer?.highlightMultipleRanges(ranges)
if (ranges.length > 0) {
setCurrentResultIndex(0)
highlightRenderer?.setActiveHighlight(ranges[0])
} else {
setCurrentResultIndex(-1)
}
},
[dispatch, editor],
[editor],
)
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
const handleQueryChange = useMemo(() => debounce(searchQueryAndHighlight, 30), [searchQueryAndHighlight])
const handleEditorChange = useMemo(() => debounce(searchQueryAndHighlight, 250), [searchQueryAndHighlight])
useEffect(() => {
if (!query) {
dispatch({ type: 'clear-results' })
dispatch({ type: 'set-current-result-index', index: -1 })
return
}
void handleQueryChange(query, isCaseSensitiveRef.current)
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
void handleQueryChange(query, isCaseSensitive)
}, [handleQueryChange, isCaseSensitive, query])
useEffect(() => {
const handleCaseSensitiveChange = () => {
void handleSearch(queryRef.current, isCaseSensitive)
}
handleCaseSensitiveChange()
}, [handleSearch, isCaseSensitive, queryRef])
useLayoutEffect(() => {
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
if (
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
@@ -178,136 +206,261 @@ export const SearchPlugin = () => {
})
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
useEffect(() => {
return addReplaceEventListener((event) => {
const { replace, type } = event
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
const { node, startIndex, endIndex } = result
const lexicalNode = $getNearestNodeFromDOMNode(node)
if (!lexicalNode) {
return
}
if (lexicalNode instanceof TextNode) {
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
}
if (scrollIntoView && node.parentElement) {
node.parentElement.scrollIntoView({
block: 'center',
})
}
const $replaceResult = useCallback(
(result: Range, scrollIntoView = false) => {
const selection = $createRangeSelection()
selection.applyDOMRange(result)
selection.insertText(replaceQuery)
const nodeParent = result.startContainer.parentElement
if (nodeParent && scrollIntoView) {
nodeParent.scrollIntoView({
block: 'center',
})
}
},
[replaceQuery],
)
editor.update(() => {
if (type === 'next') {
const result = resultsRef.current[currentResultIndexRef.current]
if (!result) {
return
}
replaceResult(result, true)
} else if (type === 'all') {
const results = resultsRef.current
for (const result of results) {
replaceResult(result)
}
}
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
})
})
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
useEffect(() => {
const currentHighlights = document.querySelectorAll('.search-highlight')
for (const element of currentHighlights) {
element.remove()
}
if (currentResultIndex === -1) {
const replaceCurrentResult = useCallback(() => {
const currentResult = results[currentResultIndex]
if (!currentResult) {
return
}
const result = results[currentResultIndex]
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
const containerElement = rootElement?.parentElement?.getElementsByClassName('search-highlight-container')[0]
result.node.parentElement?.scrollIntoView({
block: 'center',
})
if (!rootElement || !containerElement) {
return
}
createSearchHighlightElement(result, rootElement, containerElement)
})
}, [currentResultIndex, editor, results])
editor.update(
() => {
$replaceResult(currentResult, true)
},
{
discrete: true,
tag: 'skip-dom-selection',
},
)
searchQueryAndHighlight(query, isCaseSensitive)
}, [$replaceResult, currentResultIndex, editor, isCaseSensitive, query, results, searchQueryAndHighlight])
useEffect(() => {
let containerElement: HTMLElement | null | undefined
let rootElement: HTMLElement | null | undefined
editor.getEditorState().read(() => {
rootElement = editor.getRootElement()
containerElement = rootElement?.parentElement?.querySelector('.search-highlight-container')
})
if (!rootElement || !containerElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.height = `${rootElement.scrollHeight}px`
containerElement.style.overflow = 'visible'
})
resizeObserver.observe(rootElement)
const handleScroll = () => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.top = `-${rootElement.scrollTop}px`
}
rootElement.addEventListener('scroll', handleScroll)
return () => {
resizeObserver.disconnect()
rootElement?.removeEventListener('scroll', handleScroll)
}
}, [editor])
const selectCurrentResult = useCallback(() => {
const replaceAllResults = useCallback(() => {
if (results.length === 0) {
return
}
const result = results[currentResultIndex]
if (!result) {
return
}
editor.update(() => {
const rangeSelection = $createRangeSelection()
$setSelection(rangeSelection)
editor.update(
() => {
for (let i = 0; i < results.length; i++) {
const result = results[i]
if (!result) {
continue
}
$replaceResult(result, false)
}
},
{
discrete: true,
tag: 'skip-dom-selection',
},
)
searchQueryAndHighlight(query, isCaseSensitive)
}, [$replaceResult, editor, isCaseSensitive, query, results, searchQueryAndHighlight])
const lexicalNode = $getNearestNodeFromDOMNode(result.node)
if ($isTextNode(lexicalNode)) {
lexicalNode.select(result.startIndex, result.endIndex)
}
})
}, [currentResultIndex, editor, results])
const [isMounted, setElement] = useLifecycleAnimation({
open: isSearchActive,
enter: TranslateFromTopAnimation,
exit: TranslateToTopAnimation,
})
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
}
}, [])
const commandService = useCommandService()
const searchToggleShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
[commandService],
)
const toggleReplaceShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
[commandService],
)
const caseSensitivityShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
[commandService],
)
if (!isMounted) {
return null
}
return (
<>
<SearchDialog
open={isSearchActive}
closeDialog={() => {
selectCurrentResult()
dispatch({ type: 'toggle-search' })
dispatch({ type: 'reset-search' })
editor.focus()
}}
/>
<div
className={classNames(
'absolute left-2 right-6 top-2 z-10 flex select-none rounded border border-border bg-default font-sans md:left-auto',
editor.isEditable() ? 'md:top-13' : 'md:top-3',
)}
ref={setElement}
>
{editor.isEditable() && (
<button
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
onClick={toggleReplaceMode}
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
>
{isReplaceMode ? (
<ArrowDownIcon className="h-4 w-4 fill-text" />
) : (
<ArrowRightIcon className="h-4 w-4 fill-text" />
)}
</button>
)}
<div
className="flex flex-col gap-2 px-2 py-2"
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
closeDialog()
}
}}
>
<div className="flex items-center gap-2">
<DecoratedInput
placeholder="Search"
className={{
container: classNames('flex-grow !text-[length:inherit]', !query.length && '!py-1'),
right: '!py-1',
}}
value={query}
onChange={setQuery}
onKeyDown={(event) => {
if (event.key === 'Enter' && results.length) {
if (event.shiftKey) {
goToPrevResult()
return
}
goToNextResult()
}
}}
ref={focusOnMount}
right={[
<div className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right">
{query.length > 0 && (
<>
{currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null}
{results.length}
</>
)}
</div>,
]}
/>
<label
className={classNames(
'relative flex items-center rounded border px-1.5 py-1 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
)}
title={`Case sensitive (${caseSensitivityShortcut})`}
>
<input
type="checkbox"
className="absolute left-0 top-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
checked={isCaseSensitive}
onChange={toggleCaseSensitivity}
/>
<span aria-hidden>Aa</span>
<span className="sr-only">Case sensitive</span>
</label>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={goToPrevResult}
disabled={results.length < 1}
title="Previous result (Shift + Enter)"
>
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={goToNextResult}
disabled={results.length < 1}
title="Next result (Enter)"
>
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
onClick={() => {
closeDialog()
}}
title={`Close (${searchToggleShortcut})`}
>
<CloseIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
{isReplaceMode && (
<div className="flex flex-wrap items-center gap-2 md:flex-nowrap">
<input
type="text"
placeholder="Replace"
onChange={(e) => {
setReplaceQuery(e.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && replaceQuery && results.length) {
if (event.ctrlKey && event.altKey) {
replaceAllResults()
event.preventDefault()
return
}
replaceCurrentResult()
event.preventDefault()
}
}}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
/>
<Button
small
onClick={replaceCurrentResult}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace (Ctrl + Enter)"
>
Replace
</Button>
<Button
small
onClick={replaceAllResults}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace all (Ctrl + Alt + Enter)"
>
Replace all
</Button>
</div>
)}
<div className="flex items-center gap-2">
<label className="inline-flex items-center gap-2">
<input
className="h-4 w-4 rounded accent-info"
type="checkbox"
checked={shouldHighlightAll}
onChange={(e) => setShouldHighlightAll(e.target.checked)}
/>
<div>Highlight all results</div>
</label>
{!canUseCSSHiglights && (
<StyledTooltip
label="May lead to performance degradation, especially on large documents."
className="!z-modal"
showOnMobile
portal={false}
>
<button className="cursor-default">
<Icon type="info" size="medium" />
</button>
</StyledTooltip>
)}
</div>
</div>
</div>
{createPortal(
<SearchHighlightRenderer shouldHighlightAll={shouldHighlightAll} ref={highlightRendererRef} />,
editor.getRootElement()?.parentElement || document.body,
)}
</>
)
}

View File

@@ -1,31 +0,0 @@
export type SuperSearchResult = {
node: Text
startIndex: number
endIndex: number
}
export type SuperSearchContextState = {
query: string
results: SuperSearchResult[]
currentResultIndex: number
isCaseSensitive: boolean
isSearchActive: boolean
isReplaceMode: boolean
}
export type SuperSearchContextAction =
| { type: 'set-query'; query: string }
| { type: 'set-results'; results: SuperSearchResult[] }
| { type: 'clear-results' }
| { type: 'set-current-result-index'; index: number }
| { type: 'go-to-next-result' }
| { type: 'go-to-previous-result' }
| { type: 'toggle-case-sensitive' }
| { type: 'toggle-replace-mode' }
| { type: 'toggle-search' }
| { type: 'reset-search' }
export type SuperSearchReplaceEvent = {
type: 'next' | 'all'
replace: string
}

View File

@@ -1,40 +0,0 @@
import { SuperSearchResult } from './Types'
export const createSearchHighlightElement = (
result: SuperSearchResult,
rootElement: Element,
containerElement: Element,
) => {
const rootElementRect = rootElement.getBoundingClientRect()
const range = document.createRange()
range.setStart(result.node, result.startIndex)
range.setEnd(result.node, result.endIndex)
const rects = range.getClientRects()
Array.from(rects).forEach((rect, index) => {
const id = `search-${result.startIndex}-${result.endIndex}-${index}`
const existingHighlightElement = document.getElementById(id)
if (existingHighlightElement) {
return
}
const highlightElement = document.createElement('div')
highlightElement.style.position = 'absolute'
highlightElement.style.zIndex = '1000'
highlightElement.style.transform = `translate(${rect.left - rootElementRect.left}px, ${
rect.top - rootElementRect.top + rootElement.scrollTop
}px)`
highlightElement.style.width = `${rect.width}px`
highlightElement.style.height = `${rect.height}px`
highlightElement.style.backgroundColor = 'var(--sn-stylekit-info-color)'
highlightElement.style.opacity = '0.5'
highlightElement.className = 'search-highlight'
highlightElement.id = id
containerElement.appendChild(highlightElement)
})
}

View File

@@ -1,10 +0,0 @@
export const getAllTextNodesInElement = (element: HTMLElement) => {
const textNodes: Text[] = []
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
let node = walk.nextNode()
while (node) {
textNodes.push(node as Text)
node = walk.nextNode()
}
return textNodes
}

View File

@@ -0,0 +1,322 @@
/**
* @jest-environment jsdom
*/
import { searchInElement } from './searchInElement'
function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
options: {
children?: HTMLElement[]
text?: string
} = {},
) {
const element = document.createElement(tag)
const { text } = options
if (text) {
element.textContent = text
}
return element
}
const singularSpanInDiv = () => {
const div = createElement('div')
const span = createElement('span', {
text: 'Hello world',
})
div.append(span)
return div
}
function expectRange(range: Range, [startNode, startIdx, endNode, endIdx]: [Node, number, Node, number]) {
expect(range.startContainer).toBe(startNode)
expect(range.startOffset).toBe(startIdx)
expect(range.endContainer).toBe(endNode)
expect(range.endOffset).toBe(endIdx)
}
describe('searchInElement', () => {
test('empty query', () => {
const div = createElement('div')
const results = searchInElement(div, '', false)
expect(results.length).toBe(0)
})
test('empty text node', () => {
const span = createElement('span')
const text = document.createTextNode('')
span.append(text)
const results = searchInElement(span, 'hello', false)
expect(results.length).toBe(0)
})
describe('basic search', () => {
test('search for word in single node, case-insensitive', () => {
const div = singularSpanInDiv()
const span = div.children[0]
const results = searchInElement(div, 'hello', false)
expect(results.length).toBe(1)
const node = span.childNodes[0]
expectRange(results[0], [node, 0, node, 5])
})
test('search for char in single node, case-insensitive', () => {
const div = createElement('div')
const span = createElement('span', { text: 'l' })
div.append(span)
const results = searchInElement(div, 'l', false)
expect(results.length).toBe(1)
const node = span.childNodes[0]
expectRange(results[0], [node, 0, node, 1])
})
})
describe('case sensitivity', () => {
test('valid', () => {
const div = singularSpanInDiv()
const span = div.children[0]
const results = searchInElement(div, 'Hello', true)
expect(results.length).toBe(1)
const node = span.childNodes[0]
expectRange(results[0], [node, 0, node, 5])
})
test('invalid', () => {
const div = singularSpanInDiv()
const results = searchInElement(div, 'hello', true)
expect(results.length).toBe(0)
})
})
describe('multiple in one node', () => {
test('search for l in single node which has multiple occurances of it, case-insensitive', () => {
const span = createElement('span', { text: 'Elelelo' })
const node = span.childNodes[0]
let results = searchInElement(span, 'l', false)
expect(results.length).toBe(3)
expectRange(results[0], [node, 1, node, 2])
expectRange(results[1], [node, 3, node, 4])
expectRange(results[2], [node, 5, node, 6])
})
test('search for e in single node which has multiple occurances of it, case-sensitive', () => {
const span = createElement('span', { text: 'Elelelo' })
const node = span.childNodes[0]
const results = searchInElement(span, 'e', true)
expect(results.length).toBe(2)
expectRange(results[0], [node, 2, node, 3])
expectRange(results[1], [node, 4, node, 5])
})
test('search for e in single node where all chars are e but varying case, case-insensitive', () => {
const span = createElement('span', { text: 'EeEeEe' })
const node = span.childNodes[0]
const results = searchInElement(span, 'e', false)
expect(results.length).toBe(6)
expectRange(results[0], [node, 0, node, 1])
expectRange(results[1], [node, 1, node, 2])
expectRange(results[2], [node, 2, node, 3])
expectRange(results[3], [node, 3, node, 4])
expectRange(results[4], [node, 4, node, 5])
expectRange(results[5], [node, 5, node, 6])
})
test('search for e in single node where all chars are e but varying case, case-sensitive', () => {
const span = createElement('span', { text: 'EeEeEe' })
const node = span.childNodes[0]
const results = searchInElement(span, 'e', true)
expect(results.length).toBe(3)
expectRange(results[0], [node, 1, node, 2])
expectRange(results[1], [node, 3, node, 4])
expectRange(results[2], [node, 5, node, 6])
})
})
test('search for e in multiple nodes which have multiple occurances of it, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Elloello' })
const span2 = createElement('span', { text: 'Olleolle' })
div.append(span1, span2)
const node1 = span1.childNodes[0]
const node2 = span2.childNodes[0]
let results = searchInElement(div, 'e', false)
expect(results.length).toBe(4)
expectRange(results[0], [node1, 0, node1, 1])
expectRange(results[1], [node1, 4, node1, 5])
expectRange(results[2], [node2, 3, node2, 4])
expectRange(results[3], [node2, 7, node2, 8])
})
describe('Single across multiple nodes', () => {
test('search for "Hello World" across 2 nodes, where they combine to make up the whole query, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hello ' })
const span2 = createElement('span', { text: 'World' })
div.append(span1, span2)
const results = searchInElement(div, 'Hello World', false)
expect(results.length).toBe(1)
expectRange(results[0], [span1.childNodes[0], 0, span2.childNodes[0], 5])
})
test('search for "lo wo" across 3 nodes, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hello' })
const span2 = createElement('span', { text: ' ' })
const span3 = createElement('span', { text: 'World' })
div.append(span1, span2, span3)
const results = searchInElement(div, 'lo wo', false)
expect(results.length).toBe(1)
expectRange(results[0], [span1.childNodes[0], 3, span3.childNodes[0], 2])
})
test('search for "lo wo" across 5 nodes with varying case, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hel' })
const span2 = createElement('span', { text: 'lo' })
const span3 = createElement('span', { text: ' ' })
const span4 = createElement('span', { text: 'Wo' })
const span5 = createElement('span', { text: 'rld' })
div.append(span1, span2, span3, span4, span5)
const results = searchInElement(div, 'lo wo', false)
expect(results.length).toBe(1)
expectRange(results[0], [span2.childNodes[0], 0, span4.childNodes[0], 2])
})
test('search for "lo wo" across 5 nodes with varying case, case-sensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hel' })
const span2 = createElement('span', { text: 'lo' })
const span3 = createElement('span', { text: ' ' })
const span4 = createElement('span', { text: 'Wo' })
const span5 = createElement('span', { text: 'rld' })
div.append(span1, span2, span3, span4, span5)
const results = searchInElement(div, 'lo wo', true)
expect(results.length).toBe(0)
})
})
describe('Multiple across multiple nodes', () => {
test('search for "Hello" across 5 nodes where some combine to make up the whole query, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hel' })
const span2 = createElement('span', { text: 'lo' })
const span3 = createElement('span', { text: ' ' })
const span4 = createElement('span', { text: 'He' })
const span5 = createElement('span', { text: 'llo' })
div.append(span1, span2, span3, span4, span5)
const results = searchInElement(div, 'Hello', false)
expect(results.length).toBe(2)
expectRange(results[0], [span1.childNodes[0], 0, span2.childNodes[0], 2])
expectRange(results[1], [span4.childNodes[0], 0, span5.childNodes[0], 3])
})
test('search for "Hello" across 5 nodes where one node has the whole query and some combine to make up the whole query, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'Hello' })
const span2 = createElement('span', { text: ' ' })
const span3 = createElement('span', { text: 'He' })
const span4 = createElement('span', { text: 'llo' })
div.append(span1, span2, span3, span4)
const results = searchInElement(div, 'Hello', false)
expect(results.length).toBe(2)
expectRange(results[0], [span1.childNodes[0], 0, span1.childNodes[0], 5])
expectRange(results[1], [span3.childNodes[0], 0, span4.childNodes[0], 3])
})
test('search for "Hello" across 5 nodes where one node has the whole query and some combine to make up the whole query, case-sensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'hello' })
const span2 = createElement('span', { text: ' ' })
const span3 = createElement('span', { text: 'He' })
const span4 = createElement('span', { text: 'llo' })
div.append(span1, span2, span3, span4)
const results = searchInElement(div, 'Hello', true)
expect(results.length).toBe(1)
expectRange(results[0], [span3.childNodes[0], 0, span4.childNodes[0], 3])
})
})
describe('Repeating characters', () => {
test('search for word in 1 node where it is preceding by the same char as the start of the query, case-insensitive', () => {
let span = createElement('span', { text: 'ttest' })
let results = searchInElement(span, 'test', false)
expect(results.length).toBe(1)
expectRange(results[0], [span.childNodes[0], 1, span.childNodes[0], 5])
span = createElement('span', { text: 'ffast' })
results = searchInElement(span, 'fast', false)
expect(results.length).toBe(1)
expectRange(results[0], [span.childNodes[0], 1, span.childNodes[0], 5])
})
test('search for word in 1 node where it is preceding by the same char as the start of the query, case-sensitive', () => {
const span = createElement('span', { text: 'tTest' })
const results = searchInElement(span, 'test', true)
expect(results.length).toBe(0)
})
test('search for word in 1 node where it is preceding by the same char as the start of the query multiple times, case-insensitive', () => {
const span = createElement('span', { text: 'ttestttest' })
const results = searchInElement(span, 'test', false)
expect(results.length).toBe(2)
expectRange(results[0], [span.childNodes[0], 1, span.childNodes[0], 5])
expectRange(results[1], [span.childNodes[0], 6, span.childNodes[0], 10])
})
test('search for word in 1 node where it is preceding by the same char as the start of the query multiple times, case-insensitive', () => {
const span = createElement('span', { text: 'ttesttTest' })
const results = searchInElement(span, 'test', true)
expect(results.length).toBe(1)
expectRange(results[0], [span.childNodes[0], 1, span.childNodes[0], 5])
})
test('search for word across 2 nodes where it is preceding by the same char as the start of the query, case-insensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'tte' })
const span2 = createElement('span', { text: 'stt' })
div.append(span1, span2)
const results = searchInElement(div, 'test', false)
expect(results.length).toBe(1)
expectRange(results[0], [span1.childNodes[0], 1, span2.childNodes[0], 2])
})
test('search for word across 2 nodes where it is preceding by the same char as the start of the query, case-sensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'tTe' })
const span2 = createElement('span', { text: 'stt' })
div.append(span1, span2)
const results = searchInElement(div, 'test', true)
expect(results.length).toBe(0)
})
test('search for word in 2 nodes where the last char of 1st node is the same char as the start of the query and the word starts in the 2nd node, case-sensitive', () => {
const div = createElement('div')
const span1 = createElement('span', { text: 'stt' })
const span2 = createElement('span', { text: 'testt' })
div.append(span1, span2)
const results = searchInElement(div, 'test', false)
expect(results.length).toBe(1)
expectRange(results[0], [span2.childNodes[0], 0, span2.childNodes[0], 4])
})
})
})

View File

@@ -0,0 +1,141 @@
/**
* Searches for a given query in an element and returns `Range`s for all the results.
*
* How it works:
*
* We traverse every text node in the element using a TreeWalker. Within every node,
* we loop through each of the characters of both the node text and the search query,
* trying to match both of the characters.
*
* If the node text char matches the query char:
*
* - Set start container and offset values if not already existing, meaning we are at
* the start of a potential result.
* - If we are at the last char of the query, set the end container and offset values.
* We have a full match.
* - Otherwise, we increment the query char index so that on the next text char it
* can be matched.
* - We keep track of the latest query char index outside the node loop so that we can
* search for text across nodes.
* - If we don't have an end yet, then we store the current query char index so that
* we can use it in the next node to continue the result.
* - Otherwise, we reset it to -1
* - Finally if/when we have both start and end container and offsets, we can create a
* `Range`.
*
* If the node text char doesn't match the query char, then we reset all the intermediary
* state and start again from the next character.
*/
export function searchInElement(element: HTMLElement, searchQuery: string, isCaseSensitive: boolean): Range[] {
const ranges: Range[] = []
let query = searchQuery
if (!query) {
return ranges
}
if (!isCaseSensitive) {
query = query.toLowerCase()
}
const queryLength = query.length
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
let node = walk.nextNode()
let queryCharIndexToContinueFrom = -1
let startContainer: Node | null = null
let startOffset = -1
let endContainer: Node | null = null
let endOffset = -1
while (node) {
let nodeText = node.textContent
if (!nodeText) {
node = walk.nextNode()
continue
}
nodeText = isCaseSensitive ? nodeText : nodeText.toLowerCase()
const nodeTextLength = nodeText.length
let textCharIndex = 0
let queryCharIndex = queryCharIndexToContinueFrom > -1 ? queryCharIndexToContinueFrom : 0
for (; textCharIndex < nodeTextLength; textCharIndex++) {
const textChar = nodeText[textCharIndex]
let queryChar = query[queryCharIndex]
const didMatchCharacters = textChar === queryChar
if (!didMatchCharacters) {
startContainer = null
startOffset = -1
const currentQueryIndex = queryCharIndex
queryCharIndex = 0
queryCharIndexToContinueFrom = -1
// edge-case: when searching something like `te` if the content has something like `ttest`,
// the `te` won't match since we will have reset
const prevQueryChar = currentQueryIndex > 0 ? query[currentQueryIndex - 1] : null
if (textChar === prevQueryChar) {
queryCharIndex = currentQueryIndex - 1
queryChar = prevQueryChar
} else {
continue
}
}
if (!startContainer || startOffset === -1) {
startContainer = node
startOffset = textCharIndex
}
const indexOfLastCharOfQuery = queryLength - 1
// last char of query, meaning we matched the whole query
const isLastCharOfQuery = queryCharIndex === indexOfLastCharOfQuery
if (isLastCharOfQuery) {
endContainer = node
const nextIdx = textCharIndex + 1
endOffset = nextIdx
}
// we have a potential start but query is not fully matched yet
if (queryCharIndex < indexOfLastCharOfQuery) {
queryCharIndex++
}
// we dont have an end yet so we keep the latest query index so that it
// can be carried forward to the next node.
if (queryCharIndex > -1 && !endContainer) {
queryCharIndexToContinueFrom = queryCharIndex
} else {
// reset query index since we found the end
queryCharIndexToContinueFrom = -1
}
if (endContainer && endOffset > -1) {
// create range since we have a full match
const range = new Range()
range.setStart(startContainer, startOffset)
range.setEnd(endContainer, endOffset)
ranges.push(range)
// start over
startContainer = null
startOffset = -1
endContainer = null
endOffset = -1
queryCharIndex = 0
}
}
node = walk.nextNode()
}
return ranges
}

View File

@@ -0,0 +1,7 @@
declare class Highlight extends Set<AbstractRange> {
constructor(...range: Range[])
}
declare namespace CSS {
const highlights: Map<string, Highlight>
}

View File

@@ -7647,7 +7647,7 @@ __metadata:
npm-check-updates: ^16.10.17
prettier: 3.0.0
sass-loader: ^13.3.2
typescript: 5.2.2
typescript: 5.8.3
webpack: ^5.88.2
webpack-cli: ^5.1.4
webpack-dev-server: ^4.15.1
@@ -27706,13 +27706,13 @@ __metadata:
languageName: node
linkType: hard
"typescript@npm:5.2.2":
version: 5.2.2
resolution: "typescript@npm:5.2.2"
"typescript@npm:5.8.3":
version: 5.8.3
resolution: "typescript@npm:5.8.3"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 7912821dac4d962d315c36800fe387cdc0a6298dba7ec171b350b4a6e988b51d7b8f051317786db1094bd7431d526b648aba7da8236607febb26cf5b871d2d3c
checksum: cb1d081c889a288b962d3c8ae18d337ad6ee88a8e81ae0103fa1fecbe923737f3ba1dbdb3e6d8b776c72bc73bfa6d8d850c0306eed1a51377d2fccdfd75d92c4
languageName: node
linkType: hard
@@ -27736,13 +27736,13 @@ __metadata:
languageName: node
linkType: hard
"typescript@patch:typescript@5.2.2#~builtin<compat/typescript>":
version: 5.2.2
resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin<compat/typescript>::version=5.2.2&hash=7ad353"
"typescript@patch:typescript@5.8.3#~builtin<compat/typescript>":
version: 5.8.3
resolution: "typescript@patch:typescript@npm%3A5.8.3#~builtin<compat/typescript>::version=5.8.3&hash=7ad353"
bin:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 07106822b4305de3f22835cbba949a2b35451cad50888759b6818421290ff95d522b38ef7919e70fb381c5fe9c1c643d7dea22c8b31652a717ddbd57b7f4d554
checksum: 1b503525a88ff0ff5952e95870971c4fb2118c17364d60302c21935dedcd6c37e6a0a692f350892bafcef6f4a16d09073fe461158547978d2f16fbe4cb18581c
languageName: node
linkType: hard