fix: Fix issue where searching across formatted text would not work in Super note (#2897) (skip e2e)
This commit is contained in:
Binary file not shown.
BIN
.yarn/cache/typescript-npm-5.8.3-fbd7aef456-cb1d081c88.zip
vendored
Normal file
BIN
.yarn/cache/typescript-npm-5.8.3-fbd7aef456-cb1d081c88.zip
vendored
Normal file
Binary file not shown.
Binary file not shown.
BIN
.yarn/cache/typescript-patch-9c32a45e8a-1b503525a8.zip
vendored
Normal file
BIN
.yarn/cache/typescript-patch-9c32a45e8a-1b503525a8.zip
vendored
Normal file
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
7
packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/types.d.ts
vendored
Normal file
7
packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/types.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
declare class Highlight extends Set<AbstractRange> {
|
||||
constructor(...range: Range[])
|
||||
}
|
||||
|
||||
declare namespace CSS {
|
||||
const highlights: Map<string, Highlight>
|
||||
}
|
||||
18
yarn.lock
18
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user