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:
Aman Harwara
2023-01-12 18:57:41 +05:30
committed by GitHub
parent 2fc365434f
commit 8104522658
21 changed files with 1180 additions and 45 deletions

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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()
}}
/>
</>
)
}

View File

@@ -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
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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>
)