feat: Added search and replace to Super notes on web/desktop. Press Ctrl+F in a super note to toggle search. (skip e2e) (#2128)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCommandService } from '@/Components/CommandProvider'
|
||||
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
|
||||
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CloseIcon,
|
||||
ReplaceIcon,
|
||||
ReplaceAllIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@standardnotes/icons'
|
||||
import {
|
||||
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'
|
||||
|
||||
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
|
||||
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="absolute right-6 top-4 z-10 flex select-none rounded border border-border bg-default"
|
||||
ref={setElement}
|
||||
>
|
||||
<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 py-2 px-2"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: 'set-query',
|
||||
query: e.target.value,
|
||||
})
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && results.length) {
|
||||
if (event.shiftKey) {
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="rounded border border-border bg-default p-1 px-2"
|
||||
ref={focusOnMount}
|
||||
/>
|
||||
{results.length > 0 ? (
|
||||
<span className="min-w-[10ch] text-text">
|
||||
{currentResultIndex > -1 ? currentResultIndex + 1 + ' of ' : null}
|
||||
{results.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-[10ch] text-text">No results</span>
|
||||
)}
|
||||
<label
|
||||
className={classNames(
|
||||
'relative flex items-center rounded border py-1 px-1.5 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 top-0 left-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 items-center gap-2">
|
||||
<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
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'next',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace (Ctrl + Enter)"
|
||||
>
|
||||
<ReplaceIcon 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={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'all',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace all (Ctrl + Alt + Enter)"
|
||||
>
|
||||
<ReplaceAllIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $getNearestNodeFromDOMNode, TextNode } 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 {
|
||||
SUPER_SEARCH_NEXT_RESULT,
|
||||
SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
SUPER_TOGGLE_SEARCH,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { useStateRef } from '@/Hooks/useStateRef'
|
||||
|
||||
export const SearchPlugin = () => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
|
||||
useSuperSearchContext()
|
||||
const queryRef = useStateRef(query)
|
||||
const currentResultIndexRef = useStateRef(currentResultIndex)
|
||||
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
|
||||
const resultsRef = useStateRef(results)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchActive) {
|
||||
editor.focus()
|
||||
}
|
||||
}, [editor, isSearchActive])
|
||||
|
||||
useEffect(() => {
|
||||
const isFocusInEditor = () => {
|
||||
if (!document.activeElement || !document.activeElement.closest('.blocks-editor')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return application.keyboardService.addCommandHandlers([
|
||||
{
|
||||
command: SUPER_TOGGLE_SEARCH,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-search' })
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-replace-mode' })
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
onKeyDown() {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'toggle-case-sensitive',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_NEXT_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [application.keyboardService, dispatch, editor])
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string, isCaseSensitive: boolean) => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
|
||||
if (!query) {
|
||||
dispatch({ type: 'clear-results' })
|
||||
return
|
||||
}
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (!rootElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const textNodes = getAllTextNodesInElement(rootElement)
|
||||
|
||||
const results: SuperSearchResult[] = []
|
||||
|
||||
textNodes.forEach((node) => {
|
||||
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)
|
||||
}
|
||||
|
||||
indices.forEach((index) => {
|
||||
const startIndex = index
|
||||
const endIndex = startIndex + query.length
|
||||
|
||||
results.push({
|
||||
node,
|
||||
startIndex,
|
||||
endIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'set-results',
|
||||
results,
|
||||
})
|
||||
})
|
||||
},
|
||||
[dispatch, editor],
|
||||
)
|
||||
|
||||
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
|
||||
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
|
||||
|
||||
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])
|
||||
|
||||
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) ||
|
||||
tags.has('history-merge') ||
|
||||
prevEditorState.isEmpty()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void handleEditorChange(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
}, [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',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
if (type === 'next') {
|
||||
const result = resultsRef.current[currentResultIndexRef.current]
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
replaceResult(result, true)
|
||||
} else if (type === 'all') {
|
||||
resultsRef.current.forEach((result) => replaceResult(result))
|
||||
}
|
||||
|
||||
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
})
|
||||
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
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])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchDialog
|
||||
open={isSearchActive}
|
||||
closeDialog={() => {
|
||||
dispatch({ type: 'toggle-search' })
|
||||
dispatch({ type: 'reset-search' })
|
||||
editor.focus()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMark
|
||||
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
|
||||
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
|
||||
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
|
||||
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
||||
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
||||
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
@@ -162,48 +164,48 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
||||
return (
|
||||
<div className="font-editor relative h-full w-full">
|
||||
<ErrorBoundary>
|
||||
<>
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
<BlocksEditorComposer
|
||||
readonly={note.current.locked}
|
||||
initialValue={note.current.text}
|
||||
nodes={[FileNode, BubbleNode]}
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
<BlocksEditorComposer
|
||||
readonly={note.current.locked}
|
||||
initialValue={note.current.text}
|
||||
nodes={[FileNode, BubbleNode]}
|
||||
>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
ignoreFirstChange={controller.isTemplateNote}
|
||||
className={classNames(
|
||||
'blocks-editor relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
|
||||
lineHeight && `leading-${lineHeight.toLowerCase()}`,
|
||||
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
|
||||
)}
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
ignoreFirstChange={controller.isTemplateNote}
|
||||
className={classNames(
|
||||
'relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
|
||||
lineHeight && `leading-${lineHeight.toLowerCase()}`,
|
||||
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
|
||||
)}
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note.current} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<GetMarkdownPlugin ref={getMarkdownPlugin} />
|
||||
<DatetimePlugin />
|
||||
<PasswordPlugin />
|
||||
<AutoLinkPlugin />
|
||||
<ChangeContentCallbackPlugin
|
||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
<ExportPlugin />
|
||||
<ReadonlyPlugin note={note.current} />
|
||||
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</FilesControllerProvider>
|
||||
</LinkingControllerProvider>
|
||||
|
||||
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
|
||||
</>
|
||||
<ItemSelectionPlugin currentNote={note.current} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<GetMarkdownPlugin ref={getMarkdownPlugin} />
|
||||
<DatetimePlugin />
|
||||
<PasswordPlugin />
|
||||
<AutoLinkPlugin />
|
||||
<ChangeContentCallbackPlugin
|
||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
<ExportPlugin />
|
||||
<ReadonlyPlugin note={note.current} />
|
||||
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
|
||||
<SuperSearchContextProvider>
|
||||
<SearchPlugin />
|
||||
</SuperSearchContextProvider>
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</FilesControllerProvider>
|
||||
</LinkingControllerProvider>
|
||||
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user