diff --git a/.yarn/cache/typescript-npm-5.2.2-01717e9f84-7912821dac.zip b/.yarn/cache/typescript-npm-5.2.2-01717e9f84-7912821dac.zip deleted file mode 100644 index 62eab864741..00000000000 Binary files a/.yarn/cache/typescript-npm-5.2.2-01717e9f84-7912821dac.zip and /dev/null differ diff --git a/.yarn/cache/typescript-npm-5.8.3-fbd7aef456-cb1d081c88.zip b/.yarn/cache/typescript-npm-5.8.3-fbd7aef456-cb1d081c88.zip new file mode 100644 index 00000000000..4040bea3829 Binary files /dev/null and b/.yarn/cache/typescript-npm-5.8.3-fbd7aef456-cb1d081c88.zip differ diff --git a/.yarn/cache/typescript-patch-851d8bf7a0-07106822b4.zip b/.yarn/cache/typescript-patch-851d8bf7a0-07106822b4.zip deleted file mode 100644 index ca3cd670901..00000000000 Binary files a/.yarn/cache/typescript-patch-851d8bf7a0-07106822b4.zip and /dev/null differ diff --git a/.yarn/cache/typescript-patch-9c32a45e8a-1b503525a8.zip b/.yarn/cache/typescript-patch-9c32a45e8a-1b503525a8.zip new file mode 100644 index 00000000000..21d46c0e88e Binary files /dev/null and b/.yarn/cache/typescript-patch-9c32a45e8a-1b503525a8.zip differ diff --git a/package.json b/package.json index 7a16bd37843..6ada82e39cf 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "npm-check-updates": "^16.10.17", "prettier": "3.0.0", "sass-loader": "^13.3.2", - "typescript": "5.2.2", + "typescript": "5.8.3", "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", diff --git a/packages/filepicker/example/package.json b/packages/filepicker/example/package.json index ff0665cfe12..be793f4e96d 100644 --- a/packages/filepicker/example/package.json +++ b/packages/filepicker/example/package.json @@ -27,7 +27,7 @@ "babel-loader": "^8.2.3", "html-webpack-plugin": "^5.5.0", "ts-loader": "^9.2.6", - "typescript": "^4.0.5", + "typescript": "*", "typescript-eslint": "0.0.1-alpha.0", "webpack": "*", "webpack-cli": "*", diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts b/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts index a1f99161893..316dcea4b69 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.test.ts @@ -2,6 +2,9 @@ * @jest-environment jsdom */ +// @ts-expect-error CSS is not defined in jsdom env +global.CSS = {} + import { WebApplication } from '@/Application/WebApplication' import { NotesController } from '@/Controllers/NotesController/NotesController' import { diff --git a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx index a1d36a1cd46..a5be89c1ee0 100644 --- a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx +++ b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx @@ -19,6 +19,7 @@ const StyledTooltip = ({ type = 'label', side, documentElement, + closeOnClick = true, ...props }: { children: ReactNode @@ -30,6 +31,7 @@ const StyledTooltip = ({ type?: TooltipStoreProps['type'] side?: PopoverSide documentElement?: HTMLElement + closeOnClick?: boolean } & Partial) => { const [forceOpen, setForceOpen] = useState() @@ -69,7 +71,11 @@ const StyledTooltip = ({ const clickProps = isMobile ? {} : { - onClick: () => tooltip.hide(), + onClick: () => { + if (closeOnClick) { + tooltip.hide() + } + }, } useEffect(() => { diff --git a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx index 80a0d86efda..2ba2eb005a4 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/BlocksEditor.tsx @@ -29,7 +29,6 @@ import ToolbarPlugin from './Plugins/ToolbarPlugin/ToolbarPlugin' import { useMediaQuery, MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery' import RemoteImagePlugin from './Plugins/RemoteImagePlugin/RemoteImagePlugin' import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions' -import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context' import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin' import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin' @@ -134,9 +133,7 @@ export const BlocksEditor: FunctionComponent = ({ - - - + diff --git a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss index ab8934bf7ef..c3deb8ae327 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss +++ b/packages/web/src/javascripts/Components/SuperEditor/Lexical/Theme/base.scss @@ -40,3 +40,23 @@ user-select: none; } } + +::highlight(search-results) { + background-color: var(--sn-stylekit-info-color); + background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 60%); + color: var(--text-norm); +} + +// has to be defined separately, otherwise browsers which don't support ::highlight syntax +// will throw out the whole selector +.search-highlight { + background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 60%); +} +.active-search-highlight { + background-color: color-mix(in srgb, var(--sn-stylekit-info-color), transparent 30%); +} + +::highlight(active-search-result) { + background-color: var(--sn-stylekit-info-color); + color: var(--text-norm); +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Context.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Context.tsx deleted file mode 100644 index 82a757169fb..00000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Context.tsx +++ /dev/null @@ -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 - addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void - dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void -} - -const SuperSearchContext = createContext(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 {children} -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx deleted file mode 100644 index f1912e35726..00000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx +++ /dev/null @@ -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 ( -
- {editor.isEditable() && ( - - )} -
{ - if (event.key === KeyboardKey.Escape) { - closeDialog() - } - }} - > -
- { - 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={[ -
- {query.length > 0 && ( - <> - {currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null} - {results.length} - - )} -
, - ]} - /> - - - - -
- {isReplaceMode && ( -
- { - 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} - /> - - -
- )} -
-
- ) -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchHighlightRenderer.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchHighlightRenderer.tsx new file mode 100644 index 00000000000..f7ca1753144 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchHighlightRenderer.tsx @@ -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, + ) => { + const [editor] = useLexicalComposerContext() + + const rootElement = editor.getRootElement() + const rootElementRect = useMemo(() => { + return rootElement?.getBoundingClientRect() + }, [rootElement]) + + const [activeHighlightRange, setActiveHighlightRange] = useState() + const [activeHighlightRect, setActiveHighlightRect] = useState() + const [rangesToHighlight, setRangesToHighlight] = useState([]) + const [rangeRects, setRangeRects] = useState([]) + + 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 ( +
+ {activeHighlightRect && ( +
+ )} + {rangeRects.map((rect, index) => ( +
+ ))} +
+ ) + }, +) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx index 9a800cf21ba..55705d6fdab 100644 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx @@ -1,31 +1,113 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' -import { $getNearestNodeFromDOMNode, TextNode, $createRangeSelection, $setSelection, $isTextNode } from 'lexical' -import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react' -import { createSearchHighlightElement } from './createSearchHighlightElement' -import { useSuperSearchContext } from './Context' -import { SearchDialog } from './SearchDialog' -import { getAllTextNodesInElement } from './getAllTextNodesInElement' -import { SuperSearchResult } from './Types' -import { debounce } from '@standardnotes/utils' -import { useApplication } from '@/Components/ApplicationProvider' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useApplication } from '../../../ApplicationProvider' import { + SUPER_TOGGLE_SEARCH, + SUPER_SEARCH_TOGGLE_REPLACE_MODE, + SUPER_SEARCH_TOGGLE_CASE_SENSITIVE, SUPER_SEARCH_NEXT_RESULT, SUPER_SEARCH_PREVIOUS_RESULT, - SUPER_SEARCH_TOGGLE_CASE_SENSITIVE, - SUPER_SEARCH_TOGGLE_REPLACE_MODE, - SUPER_TOGGLE_SEARCH, + KeyboardKey, + keyboardStringForShortcut, } from '@standardnotes/ui-services' -import { useStateRef } from '@/Hooks/useStateRef' - -export const SearchPlugin = () => { +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 function SearchPlugin() { const application = useApplication() const [editor] = useLexicalComposerContext() - const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } = - useSuperSearchContext() + + const [isSearchActive, setIsSearchActive] = useState(false) + + const [query, setQuery] = useState('') const queryRef = useStateRef(query) - const currentResultIndexRef = useStateRef(currentResultIndex) + const [results, setResults] = useState([]) + + const [isCaseSensitive, setIsCaseSensitive] = useState(false) const isCaseSensitiveRef = useStateRef(isCaseSensitive) - const resultsRef = useStateRef(results) + const toggleCaseSensitivity = useCallback(() => setIsCaseSensitive((sensitive) => !sensitive), []) + + const [isReplaceMode, setIsReplaceMode] = useState(false) + const toggleReplaceMode = useCallback(() => setIsReplaceMode((enabled) => !enabled), []) + const [replaceQuery, setReplaceQuery] = useState('') + + const highlightRendererRef = useRef(null) + + const [currentResultIndex, setCurrentResultIndex] = useState(-1) + const highlightAndScrollResultIntoView = useCallback( + (index: number) => { + const result = results[index] + if (!result) { + return + } + highlightRendererRef.current?.setActiveHighlight(result) + result.startContainer.parentElement?.scrollIntoView({ + block: 'center', + }) + }, + [results], + ) + const goToNextResult = useCallback(() => { + let next = currentResultIndex + 1 + if (next >= results.length) { + next = 0 + } + highlightAndScrollResultIntoView(next) + setCurrentResultIndex(next) + }, [currentResultIndex, highlightAndScrollResultIntoView, results.length]) + const goToPrevResult = useCallback(() => { + let prev = currentResultIndex - 1 + if (prev < 0) { + prev = results.length - 1 + } + highlightAndScrollResultIntoView(prev) + setCurrentResultIndex(prev) + }, [currentResultIndex, highlightAndScrollResultIntoView, results.length]) + + const selectCurrentResult = useCallback(() => { + if (results.length === 0) { + return + } + const result = results[currentResultIndex] + if (!result) { + return + } + editor.update(() => { + const rangeSelection = $createRangeSelection() + rangeSelection.applyDOMRange(result) + $setSelection(rangeSelection) + }) + }, [currentResultIndex, editor, results]) + + const [shouldHighlightAll, setShouldHighlightAll] = useState(canUseCSSHiglights) + + const closeDialog = useCallback(() => { + selectCurrentResult() + setIsSearchActive(false) + setQuery('') + setResults([]) + setIsCaseSensitive(false) + setIsReplaceMode(false) + setReplaceQuery('') + setShouldHighlightAll(canUseCSSHiglights) + editor.update(() => { + if ($getSelection() !== null) { + editor.focus() + } + }) + }, [editor, selectCurrentResult]) useEffect(() => { return application.keyboardService.addCommandHandlers([ @@ -36,7 +118,7 @@ export const SearchPlugin = () => { onKeyDown: (event) => { event.preventDefault() event.stopPropagation() - dispatch({ type: 'toggle-search' }) + setIsSearchActive((active) => !active) }, }, { @@ -49,15 +131,13 @@ export const SearchPlugin = () => { } event.preventDefault() event.stopPropagation() - dispatch({ type: 'toggle-replace-mode' }) + toggleReplaceMode() }, }, { command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE, onKeyDown() { - dispatch({ - type: 'toggle-case-sensitive', - }) + toggleCaseSensitivity() }, }, { @@ -67,9 +147,7 @@ export const SearchPlugin = () => { onKeyDown(event) { event.preventDefault() event.stopPropagation() - dispatch({ - type: 'go-to-next-result', - }) + goToNextResult() }, }, { @@ -79,92 +157,42 @@ export const SearchPlugin = () => { onKeyDown(event) { event.preventDefault() event.stopPropagation() - dispatch({ - type: 'go-to-previous-result', - }) + goToPrevResult() }, }, ]) - }, [application.keyboardService, dispatch, editor]) + }, [application.keyboardService, editor, goToNextResult, goToPrevResult, toggleCaseSensitivity, toggleReplaceMode]) - const handleSearch = useCallback( + const searchQueryAndHighlight = useCallback( (query: string, isCaseSensitive: boolean) => { - const currentHighlights = document.querySelectorAll('.search-highlight') - for (const element of currentHighlights) { - element.remove() - } - - if (!query) { - dispatch({ type: 'clear-results' }) + const highlightRenderer = highlightRendererRef.current + const rootElement = editor.getRootElement() + if (!rootElement || !query) { + highlightRenderer?.clearHighlights() return } - - editor.getEditorState().read(() => { - const rootElement = editor.getRootElement() - - if (!rootElement) { - return - } - - const textNodes = getAllTextNodesInElement(rootElement) - - const results: SuperSearchResult[] = [] - - for (const node of textNodes) { - const text = node.textContent || '' - - const indices: number[] = [] - let index = -1 - - const textWithCase = isCaseSensitive ? text : text.toLowerCase() - const queryWithCase = isCaseSensitive ? query : query.toLowerCase() - - while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) { - indices.push(index) - } - - for (const index of indices) { - const startIndex = index - const endIndex = startIndex + query.length - - results.push({ - node, - startIndex, - endIndex, - }) - } - } - - dispatch({ - type: 'set-results', - results, - }) - }) + highlightRenderer?.clearHighlights() + const ranges = searchInElement(rootElement, query, isCaseSensitive) + setResults(ranges) + highlightRenderer?.highlightMultipleRanges(ranges) + if (ranges.length > 0) { + setCurrentResultIndex(0) + highlightRenderer?.setActiveHighlight(ranges[0]) + } else { + setCurrentResultIndex(-1) + } }, - [dispatch, editor], + [editor], ) - const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch]) - const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch]) + const handleQueryChange = useMemo(() => debounce(searchQueryAndHighlight, 30), [searchQueryAndHighlight]) + const handleEditorChange = useMemo(() => debounce(searchQueryAndHighlight, 250), [searchQueryAndHighlight]) useEffect(() => { - if (!query) { - dispatch({ type: 'clear-results' }) - dispatch({ type: 'set-current-result-index', index: -1 }) - return - } - - void handleQueryChange(query, isCaseSensitiveRef.current) - }, [dispatch, handleQueryChange, isCaseSensitiveRef, query]) + void handleQueryChange(query, isCaseSensitive) + }, [handleQueryChange, isCaseSensitive, query]) useEffect(() => { - const handleCaseSensitiveChange = () => { - void handleSearch(queryRef.current, isCaseSensitive) - } - handleCaseSensitiveChange() - }, [handleSearch, isCaseSensitive, queryRef]) - - useLayoutEffect(() => { return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => { if ( (dirtyElements.size === 0 && dirtyLeaves.size === 0) || @@ -178,136 +206,261 @@ export const SearchPlugin = () => { }) }, [editor, handleEditorChange, isCaseSensitiveRef, queryRef]) - useEffect(() => { - return addReplaceEventListener((event) => { - const { replace, type } = event - - const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => { - const { node, startIndex, endIndex } = result - const lexicalNode = $getNearestNodeFromDOMNode(node) - if (!lexicalNode) { - return - } - if (lexicalNode instanceof TextNode) { - lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true) - } - if (scrollIntoView && node.parentElement) { - node.parentElement.scrollIntoView({ - block: 'center', - }) - } - } - - editor.update(() => { - if (type === 'next') { - const result = resultsRef.current[currentResultIndexRef.current] - if (!result) { - return - } - replaceResult(result, true) - } else if (type === 'all') { - const results = resultsRef.current - for (const result of results) { - replaceResult(result) - } - } - - void handleSearch(queryRef.current, isCaseSensitiveRef.current) - }) - }) - }, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef]) - - useEffect(() => { - const currentHighlights = document.querySelectorAll('.search-highlight') - for (const element of currentHighlights) { - element.remove() - } - if (currentResultIndex === -1) { - 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 + const $replaceResult = useCallback( + (result: Range, scrollIntoView = false) => { + const selection = $createRangeSelection() + selection.applyDOMRange(result) + selection.insertText(replaceQuery) + const nodeParent = result.startContainer.parentElement + if (nodeParent && scrollIntoView) { + nodeParent.scrollIntoView({ + block: 'center', + }) } - 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') - }) + }, + [replaceQuery], + ) - if (!rootElement || !containerElement) { + const replaceCurrentResult = useCallback(() => { + const currentResult = results[currentResultIndex] + if (!currentResult) { return } + editor.update( + () => { + $replaceResult(currentResult, true) + }, + { + discrete: true, + tag: 'skip-dom-selection', + }, + ) + searchQueryAndHighlight(query, isCaseSensitive) + }, [$replaceResult, currentResultIndex, editor, isCaseSensitive, query, results, searchQueryAndHighlight]) - const resizeObserver = new ResizeObserver(() => { - if (!rootElement || !containerElement) { - return - } - - containerElement.style.height = `${rootElement.scrollHeight}px` - containerElement.style.overflow = 'visible' - }) - resizeObserver.observe(rootElement) - - const handleScroll = () => { - if (!rootElement || !containerElement) { - return - } - - containerElement.style.top = `-${rootElement.scrollTop}px` - } - - rootElement.addEventListener('scroll', handleScroll) - - return () => { - resizeObserver.disconnect() - rootElement?.removeEventListener('scroll', handleScroll) - } - }, [editor]) - - const selectCurrentResult = useCallback(() => { + const replaceAllResults = useCallback(() => { if (results.length === 0) { return } - const result = results[currentResultIndex] - if (!result) { - return + editor.update( + () => { + for (let i = 0; i < results.length; i++) { + const result = results[i] + if (!result) { + continue + } + $replaceResult(result, false) + } + }, + { + discrete: true, + tag: 'skip-dom-selection', + }, + ) + searchQueryAndHighlight(query, isCaseSensitive) + }, [$replaceResult, editor, isCaseSensitive, query, results, searchQueryAndHighlight]) + + const [isMounted, setElement] = useLifecycleAnimation({ + open: isSearchActive, + enter: TranslateFromTopAnimation, + exit: TranslateToTopAnimation, + }) + + const focusOnMount = useCallback((node: HTMLInputElement | null) => { + if (node) { + node.focus() } - editor.update(() => { - const rangeSelection = $createRangeSelection() - $setSelection(rangeSelection) + }, []) - const lexicalNode = $getNearestNodeFromDOMNode(result.node) - if ($isTextNode(lexicalNode)) { - lexicalNode.select(result.startIndex, result.endIndex) - } - }) - }, [currentResultIndex, editor, results]) + 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 ( <> - { - selectCurrentResult() - dispatch({ type: 'toggle-search' }) - dispatch({ type: 'reset-search' }) - editor.focus() - }} - /> +
+ {editor.isEditable() && ( + + )} +
{ + if (event.key === KeyboardKey.Escape) { + closeDialog() + } + }} + > +
+ { + if (event.key === 'Enter' && results.length) { + if (event.shiftKey) { + goToPrevResult() + return + } + goToNextResult() + } + }} + ref={focusOnMount} + right={[ +
+ {query.length > 0 && ( + <> + {currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null} + {results.length} + + )} +
, + ]} + /> + + + + +
+ {isReplaceMode && ( +
+ { + 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} + /> + + +
+ )} +
+ + {!canUseCSSHiglights && ( + + + + )} +
+
+
+ {createPortal( + , + editor.getRootElement()?.parentElement || document.body, + )} ) } diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Types.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Types.ts deleted file mode 100644 index daf776a94df..00000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/Types.ts +++ /dev/null @@ -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 -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts deleted file mode 100644 index 677cb70b0b4..00000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts +++ /dev/null @@ -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) - }) -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts deleted file mode 100644 index 3e450b9461d..00000000000 --- a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts +++ /dev/null @@ -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 -} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.spec.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.spec.ts new file mode 100644 index 00000000000..aff52775c91 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.spec.ts @@ -0,0 +1,322 @@ +/** + * @jest-environment jsdom + */ + +import { searchInElement } from './searchInElement' + +function createElement( + 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]) + }) + }) +}) diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.ts new file mode 100644 index 00000000000..5e141e9bff8 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/searchInElement.ts @@ -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 +} diff --git a/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/types.d.ts b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/types.d.ts new file mode 100644 index 00000000000..affc21e0859 --- /dev/null +++ b/packages/web/src/javascripts/Components/SuperEditor/Plugins/SearchPlugin/types.d.ts @@ -0,0 +1,7 @@ +declare class Highlight extends Set { + constructor(...range: Range[]) +} + +declare namespace CSS { + const highlights: Map +} diff --git a/yarn.lock b/yarn.lock index 2accb87c81e..79b467a14dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7647,7 +7647,7 @@ __metadata: npm-check-updates: ^16.10.17 prettier: 3.0.0 sass-loader: ^13.3.2 - typescript: 5.2.2 + typescript: 5.8.3 webpack: ^5.88.2 webpack-cli: ^5.1.4 webpack-dev-server: ^4.15.1 @@ -27706,13 +27706,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.2.2": - version: 5.2.2 - resolution: "typescript@npm:5.2.2" +"typescript@npm:5.8.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 7912821dac4d962d315c36800fe387cdc0a6298dba7ec171b350b4a6e988b51d7b8f051317786db1094bd7431d526b648aba7da8236607febb26cf5b871d2d3c + checksum: cb1d081c889a288b962d3c8ae18d337ad6ee88a8e81ae0103fa1fecbe923737f3ba1dbdb3e6d8b776c72bc73bfa6d8d850c0306eed1a51377d2fccdfd75d92c4 languageName: node linkType: hard @@ -27736,13 +27736,13 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@5.2.2#~builtin": - version: 5.2.2 - resolution: "typescript@patch:typescript@npm%3A5.2.2#~builtin::version=5.2.2&hash=7ad353" +"typescript@patch:typescript@5.8.3#~builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#~builtin::version=5.8.3&hash=7ad353" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 07106822b4305de3f22835cbba949a2b35451cad50888759b6818421290ff95d522b38ef7919e70fb381c5fe9c1c643d7dea22c8b31652a717ddbd57b7f4d554 + checksum: 1b503525a88ff0ff5952e95870971c4fb2118c17364d60302c21935dedcd6c37e6a0a692f350892bafcef6f4a16d09073fe461158547978d2f16fbe4cb18581c languageName: node linkType: hard