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",
|
"npm-check-updates": "^16.10.17",
|
||||||
"prettier": "3.0.0",
|
"prettier": "3.0.0",
|
||||||
"sass-loader": "^13.3.2",
|
"sass-loader": "^13.3.2",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.8.3",
|
||||||
"webpack": "^5.88.2",
|
"webpack": "^5.88.2",
|
||||||
"webpack-cli": "^5.1.4",
|
"webpack-cli": "^5.1.4",
|
||||||
"webpack-dev-server": "^4.15.1",
|
"webpack-dev-server": "^4.15.1",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"babel-loader": "^8.2.3",
|
"babel-loader": "^8.2.3",
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
"typescript": "^4.0.5",
|
"typescript": "*",
|
||||||
"typescript-eslint": "0.0.1-alpha.0",
|
"typescript-eslint": "0.0.1-alpha.0",
|
||||||
"webpack": "*",
|
"webpack": "*",
|
||||||
"webpack-cli": "*",
|
"webpack-cli": "*",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* @jest-environment jsdom
|
* @jest-environment jsdom
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// @ts-expect-error CSS is not defined in jsdom env
|
||||||
|
global.CSS = {}
|
||||||
|
|
||||||
import { WebApplication } from '@/Application/WebApplication'
|
import { WebApplication } from '@/Application/WebApplication'
|
||||||
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const StyledTooltip = ({
|
|||||||
type = 'label',
|
type = 'label',
|
||||||
side,
|
side,
|
||||||
documentElement,
|
documentElement,
|
||||||
|
closeOnClick = true,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -30,6 +31,7 @@ const StyledTooltip = ({
|
|||||||
type?: TooltipStoreProps['type']
|
type?: TooltipStoreProps['type']
|
||||||
side?: PopoverSide
|
side?: PopoverSide
|
||||||
documentElement?: HTMLElement
|
documentElement?: HTMLElement
|
||||||
|
closeOnClick?: boolean
|
||||||
} & Partial<TooltipOptions>) => {
|
} & Partial<TooltipOptions>) => {
|
||||||
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
|
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
|
||||||
|
|
||||||
@@ -69,7 +71,11 @@ const StyledTooltip = ({
|
|||||||
const clickProps = isMobile
|
const clickProps = isMobile
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
onClick: () => tooltip.hide(),
|
onClick: () => {
|
||||||
|
if (closeOnClick) {
|
||||||
|
tooltip.hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import ToolbarPlugin from './Plugins/ToolbarPlugin/ToolbarPlugin'
|
|||||||
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
||||||
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
|
import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin'
|
||||||
import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
|
import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
|
||||||
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
|
||||||
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
||||||
import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin'
|
import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin'
|
||||||
import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin'
|
import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin'
|
||||||
@@ -134,9 +133,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
<RemoveBrokenTablesPlugin />
|
<RemoveBrokenTablesPlugin />
|
||||||
<RemoteImagePlugin />
|
<RemoteImagePlugin />
|
||||||
<CodeOptionsPlugin />
|
<CodeOptionsPlugin />
|
||||||
<SuperSearchContextProvider>
|
|
||||||
<SearchPlugin />
|
<SearchPlugin />
|
||||||
</SuperSearchContextProvider>
|
|
||||||
<DatetimePlugin />
|
<DatetimePlugin />
|
||||||
<PasswordPlugin />
|
<PasswordPlugin />
|
||||||
<AutoLinkPlugin />
|
<AutoLinkPlugin />
|
||||||
|
|||||||
@@ -40,3 +40,23 @@
|
|||||||
user-select: none;
|
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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||||
import { $getNearestNodeFromDOMNode, TextNode, $createRangeSelection, $setSelection, $isTextNode } from 'lexical'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
|
import { useApplication } from '../../../ApplicationProvider'
|
||||||
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 {
|
import {
|
||||||
|
SUPER_TOGGLE_SEARCH,
|
||||||
|
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||||
|
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||||
SUPER_SEARCH_NEXT_RESULT,
|
SUPER_SEARCH_NEXT_RESULT,
|
||||||
SUPER_SEARCH_PREVIOUS_RESULT,
|
SUPER_SEARCH_PREVIOUS_RESULT,
|
||||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
KeyboardKey,
|
||||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
keyboardStringForShortcut,
|
||||||
SUPER_TOGGLE_SEARCH,
|
|
||||||
} from '@standardnotes/ui-services'
|
} 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 application = useApplication()
|
||||||
const [editor] = useLexicalComposerContext()
|
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 queryRef = useStateRef(query)
|
||||||
const currentResultIndexRef = useStateRef(currentResultIndex)
|
const [results, setResults] = useState<Range[]>([])
|
||||||
|
|
||||||
|
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||||
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
|
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(() => {
|
useEffect(() => {
|
||||||
return application.keyboardService.addCommandHandlers([
|
return application.keyboardService.addCommandHandlers([
|
||||||
@@ -36,7 +118,7 @@ export const SearchPlugin = () => {
|
|||||||
onKeyDown: (event) => {
|
onKeyDown: (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
dispatch({ type: 'toggle-search' })
|
setIsSearchActive((active) => !active)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -49,15 +131,13 @@ export const SearchPlugin = () => {
|
|||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
dispatch({ type: 'toggle-replace-mode' })
|
toggleReplaceMode()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||||
onKeyDown() {
|
onKeyDown() {
|
||||||
dispatch({
|
toggleCaseSensitivity()
|
||||||
type: 'toggle-case-sensitive',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,9 +147,7 @@ export const SearchPlugin = () => {
|
|||||||
onKeyDown(event) {
|
onKeyDown(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
dispatch({
|
goToNextResult()
|
||||||
type: 'go-to-next-result',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,92 +157,42 @@ export const SearchPlugin = () => {
|
|||||||
onKeyDown(event) {
|
onKeyDown(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
dispatch({
|
goToPrevResult()
|
||||||
type: 'go-to-previous-result',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}, [application.keyboardService, dispatch, editor])
|
}, [application.keyboardService, editor, goToNextResult, goToPrevResult, toggleCaseSensitivity, toggleReplaceMode])
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const searchQueryAndHighlight = useCallback(
|
||||||
(query: string, isCaseSensitive: boolean) => {
|
(query: string, isCaseSensitive: boolean) => {
|
||||||
const currentHighlights = document.querySelectorAll('.search-highlight')
|
const highlightRenderer = highlightRendererRef.current
|
||||||
for (const element of currentHighlights) {
|
|
||||||
element.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
dispatch({ type: 'clear-results' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.getEditorState().read(() => {
|
|
||||||
const rootElement = editor.getRootElement()
|
const rootElement = editor.getRootElement()
|
||||||
|
if (!rootElement || !query) {
|
||||||
if (!rootElement) {
|
highlightRenderer?.clearHighlights()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
highlightRenderer?.clearHighlights()
|
||||||
const textNodes = getAllTextNodesInElement(rootElement)
|
const ranges = searchInElement(rootElement, query, isCaseSensitive)
|
||||||
|
setResults(ranges)
|
||||||
const results: SuperSearchResult[] = []
|
highlightRenderer?.highlightMultipleRanges(ranges)
|
||||||
|
if (ranges.length > 0) {
|
||||||
for (const node of textNodes) {
|
setCurrentResultIndex(0)
|
||||||
const text = node.textContent || ''
|
highlightRenderer?.setActiveHighlight(ranges[0])
|
||||||
|
} else {
|
||||||
const indices: number[] = []
|
setCurrentResultIndex(-1)
|
||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[dispatch, editor],
|
[editor],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
|
const handleQueryChange = useMemo(() => debounce(searchQueryAndHighlight, 30), [searchQueryAndHighlight])
|
||||||
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
|
const handleEditorChange = useMemo(() => debounce(searchQueryAndHighlight, 250), [searchQueryAndHighlight])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!query) {
|
void handleQueryChange(query, isCaseSensitive)
|
||||||
dispatch({ type: 'clear-results' })
|
}, [handleQueryChange, isCaseSensitive, query])
|
||||||
dispatch({ type: 'set-current-result-index', index: -1 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleQueryChange(query, isCaseSensitiveRef.current)
|
|
||||||
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCaseSensitiveChange = () => {
|
|
||||||
void handleSearch(queryRef.current, isCaseSensitive)
|
|
||||||
}
|
|
||||||
handleCaseSensitiveChange()
|
|
||||||
}, [handleSearch, isCaseSensitive, queryRef])
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
|
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
|
||||||
if (
|
if (
|
||||||
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
|
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
|
||||||
@@ -178,136 +206,261 @@ export const SearchPlugin = () => {
|
|||||||
})
|
})
|
||||||
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
|
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
|
||||||
|
|
||||||
useEffect(() => {
|
const $replaceResult = useCallback(
|
||||||
return addReplaceEventListener((event) => {
|
(result: Range, scrollIntoView = false) => {
|
||||||
const { replace, type } = event
|
const selection = $createRangeSelection()
|
||||||
|
selection.applyDOMRange(result)
|
||||||
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
|
selection.insertText(replaceQuery)
|
||||||
const { node, startIndex, endIndex } = result
|
const nodeParent = result.startContainer.parentElement
|
||||||
const lexicalNode = $getNearestNodeFromDOMNode(node)
|
if (nodeParent && scrollIntoView) {
|
||||||
if (!lexicalNode) {
|
nodeParent.scrollIntoView({
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lexicalNode instanceof TextNode) {
|
|
||||||
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
|
|
||||||
}
|
|
||||||
if (scrollIntoView && node.parentElement) {
|
|
||||||
node.parentElement.scrollIntoView({
|
|
||||||
block: 'center',
|
block: 'center',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
[replaceQuery],
|
||||||
|
)
|
||||||
|
|
||||||
editor.update(() => {
|
const replaceCurrentResult = useCallback(() => {
|
||||||
if (type === 'next') {
|
const currentResult = results[currentResultIndex]
|
||||||
const result = resultsRef.current[currentResultIndexRef.current]
|
if (!currentResult) {
|
||||||
if (!result) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
replaceResult(result, true)
|
editor.update(
|
||||||
} else if (type === 'all') {
|
() => {
|
||||||
const results = resultsRef.current
|
$replaceResult(currentResult, true)
|
||||||
for (const result of results) {
|
},
|
||||||
replaceResult(result)
|
{
|
||||||
}
|
discrete: true,
|
||||||
}
|
tag: 'skip-dom-selection',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
searchQueryAndHighlight(query, isCaseSensitive)
|
||||||
|
}, [$replaceResult, currentResultIndex, editor, isCaseSensitive, query, results, searchQueryAndHighlight])
|
||||||
|
|
||||||
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
|
const replaceAllResults = useCallback(() => {
|
||||||
})
|
|
||||||
})
|
|
||||||
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentHighlights = document.querySelectorAll('.search-highlight')
|
|
||||||
for (const element of currentHighlights) {
|
|
||||||
element.remove()
|
|
||||||
}
|
|
||||||
if (currentResultIndex === -1) {
|
|
||||||
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])
|
|
||||||
|
|
||||||
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(() => {
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = results[currentResultIndex]
|
editor.update(
|
||||||
|
() => {
|
||||||
|
for (let i = 0; i < results.length; i++) {
|
||||||
|
const result = results[i]
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
editor.update(() => {
|
$replaceResult(result, false)
|
||||||
const rangeSelection = $createRangeSelection()
|
}
|
||||||
$setSelection(rangeSelection)
|
},
|
||||||
|
{
|
||||||
|
discrete: true,
|
||||||
|
tag: 'skip-dom-selection',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
searchQueryAndHighlight(query, isCaseSensitive)
|
||||||
|
}, [$replaceResult, editor, isCaseSensitive, query, results, searchQueryAndHighlight])
|
||||||
|
|
||||||
const lexicalNode = $getNearestNodeFromDOMNode(result.node)
|
const [isMounted, setElement] = useLifecycleAnimation({
|
||||||
if ($isTextNode(lexicalNode)) {
|
open: isSearchActive,
|
||||||
lexicalNode.select(result.startIndex, result.endIndex)
|
enter: TranslateFromTopAnimation,
|
||||||
}
|
exit: TranslateToTopAnimation,
|
||||||
})
|
})
|
||||||
}, [currentResultIndex, editor, results])
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchDialog
|
<div
|
||||||
open={isSearchActive}
|
className={classNames(
|
||||||
closeDialog={() => {
|
'absolute left-2 right-6 top-2 z-10 flex select-none rounded border border-border bg-default font-sans md:left-auto',
|
||||||
selectCurrentResult()
|
editor.isEditable() ? 'md:top-13' : 'md:top-3',
|
||||||
dispatch({ type: 'toggle-search' })
|
)}
|
||||||
dispatch({ type: 'reset-search' })
|
ref={setElement}
|
||||||
editor.focus()
|
>
|
||||||
|
{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
|
npm-check-updates: ^16.10.17
|
||||||
prettier: 3.0.0
|
prettier: 3.0.0
|
||||||
sass-loader: ^13.3.2
|
sass-loader: ^13.3.2
|
||||||
typescript: 5.2.2
|
typescript: 5.8.3
|
||||||
webpack: ^5.88.2
|
webpack: ^5.88.2
|
||||||
webpack-cli: ^5.1.4
|
webpack-cli: ^5.1.4
|
||||||
webpack-dev-server: ^4.15.1
|
webpack-dev-server: ^4.15.1
|
||||||
@@ -27706,13 +27706,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"typescript@npm:5.2.2":
|
"typescript@npm:5.8.3":
|
||||||
version: 5.2.2
|
version: 5.8.3
|
||||||
resolution: "typescript@npm:5.2.2"
|
resolution: "typescript@npm:5.8.3"
|
||||||
bin:
|
bin:
|
||||||
tsc: bin/tsc
|
tsc: bin/tsc
|
||||||
tsserver: bin/tsserver
|
tsserver: bin/tsserver
|
||||||
checksum: 7912821dac4d962d315c36800fe387cdc0a6298dba7ec171b350b4a6e988b51d7b8f051317786db1094bd7431d526b648aba7da8236607febb26cf5b871d2d3c
|
checksum: cb1d081c889a288b962d3c8ae18d337ad6ee88a8e81ae0103fa1fecbe923737f3ba1dbdb3e6d8b776c72bc73bfa6d8d850c0306eed1a51377d2fccdfd75d92c4
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -27736,13 +27736,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"typescript@patch:typescript@5.2.2#~builtin<compat/typescript>":
|
"typescript@patch:typescript@5.8.3#~builtin<compat/typescript>":
|
||||||
version: 5.2.2
|
version: 5.8.3
|
||||||
resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin<compat/typescript>::version=5.2.2&hash=7ad353"
|
resolution: "typescript@patch:typescript@npm%3A5.8.3#~builtin<compat/typescript>::version=5.8.3&hash=7ad353"
|
||||||
bin:
|
bin:
|
||||||
tsc: bin/tsc
|
tsc: bin/tsc
|
||||||
tsserver: bin/tsserver
|
tsserver: bin/tsserver
|
||||||
checksum: 07106822b4305de3f22835cbba949a2b35451cad50888759b6818421290ff95d522b38ef7919e70fb381c5fe9c1c643d7dea22c8b31652a717ddbd57b7f4d554
|
checksum: 1b503525a88ff0ff5952e95870971c4fb2118c17364d60302c21935dedcd6c37e6a0a692f350892bafcef6f4a16d09073fe461158547978d2f16fbe4cb18581c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user