diff --git a/packages/blocks-editor/src/Editor/BlocksEditor.tsx b/packages/blocks-editor/src/Editor/BlocksEditor.tsx index 750f75fff..8f926f687 100644 --- a/packages/blocks-editor/src/Editor/BlocksEditor.tsx +++ b/packages/blocks-editor/src/Editor/BlocksEditor.tsx @@ -97,12 +97,13 @@ export const BlocksEditor: FunctionComponent = ({ -
+
+
} diff --git a/packages/blocks-editor/src/Lexical/Plugins/DraggableBlockPlugin/index.tsx b/packages/blocks-editor/src/Lexical/Plugins/DraggableBlockPlugin/index.tsx index 32e71c278..9db71084b 100644 --- a/packages/blocks-editor/src/Lexical/Plugins/DraggableBlockPlugin/index.tsx +++ b/packages/blocks-editor/src/Lexical/Plugins/DraggableBlockPlugin/index.tsx @@ -443,7 +443,7 @@ function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, i onTouchEnd={onTouchEnd} >
- +
diff --git a/packages/icons/src/Icons/ic-replace-all.svg b/packages/icons/src/Icons/ic-replace-all.svg new file mode 100644 index 000000000..43075368c --- /dev/null +++ b/packages/icons/src/Icons/ic-replace-all.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/ic-replace.svg b/packages/icons/src/Icons/ic-replace.svg new file mode 100644 index 000000000..ae6cfd647 --- /dev/null +++ b/packages/icons/src/Icons/ic-replace.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/index.ts b/packages/icons/src/Icons/index.ts index 9c00bb800..63a0a3678 100644 --- a/packages/icons/src/Icons/index.ts +++ b/packages/icons/src/Icons/index.ts @@ -205,6 +205,8 @@ import EvernoteIcon from './ic-evernote.svg' import GoogleKeepIcon from './ic-gkeep.svg' import SimplenoteIcon from './ic-simplenote.svg' import AegisIcon from './ic-aegis.svg' +import ReplaceIcon from './ic-replace.svg' +import ReplaceAllIcon from './ic-replace-all.svg' export { AccessibilityIcon, @@ -414,4 +416,6 @@ export { GoogleKeepIcon, SimplenoteIcon, AegisIcon, + ReplaceIcon, + ReplaceAllIcon, } diff --git a/packages/ui-services/src/Keyboard/KeyboardCommands.ts b/packages/ui-services/src/Keyboard/KeyboardCommands.ts index bd48b28c5..c416e9307 100644 --- a/packages/ui-services/src/Keyboard/KeyboardCommands.ts +++ b/packages/ui-services/src/Keyboard/KeyboardCommands.ts @@ -25,7 +25,14 @@ export const OPEN_NOTE_HISTORY_COMMAND = createKeyboardCommand('OPEN_NOTE_HISTOR export const CAPTURE_SAVE_COMMAND = createKeyboardCommand('CAPTURE_SAVE_COMMAND') export const STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND') export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND') + +export const SUPER_TOGGLE_SEARCH = createKeyboardCommand('SUPER_TOGGLE_SEARCH') +export const SUPER_SEARCH_TOGGLE_CASE_SENSITIVE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_CASE_SENSITIVE') +export const SUPER_SEARCH_TOGGLE_REPLACE_MODE = createKeyboardCommand('SUPER_SEARCH_TOGGLE_REPLACE_MODE') +export const SUPER_SEARCH_NEXT_RESULT = createKeyboardCommand('SUPER_SEARCH_NEXT_RESULT') +export const SUPER_SEARCH_PREVIOUS_RESULT = createKeyboardCommand('SUPER_SEARCH_PREVIOUS_RESULT') export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MARKDOWN_PREVIEW') + export const SUPER_EXPORT_JSON = createKeyboardCommand('SUPER_EXPORT_JSON') export const SUPER_EXPORT_MARKDOWN = createKeyboardCommand('SUPER_EXPORT_MARKDOWN') export const SUPER_EXPORT_HTML = createKeyboardCommand('SUPER_EXPORT_HTML') diff --git a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts index cd913ef59..bb79f0bc4 100644 --- a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts +++ b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts @@ -24,6 +24,11 @@ import { SUPER_SHOW_MARKDOWN_PREVIEW, OPEN_PREFERENCES_COMMAND, TOGGLE_DARK_MODE_COMMAND, + SUPER_TOGGLE_SEARCH, + SUPER_SEARCH_TOGGLE_CASE_SENSITIVE, + SUPER_SEARCH_NEXT_RESULT, + SUPER_SEARCH_PREVIOUS_RESULT, + SUPER_SEARCH_TOGGLE_REPLACE_MODE, } from './KeyboardCommands' import { KeyboardKey } from './KeyboardKey' import { KeyboardModifier } from './KeyboardModifier' @@ -141,6 +146,30 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme modifiers: [primaryModifier, KeyboardModifier.Shift], preventDefault: true, }, + { + command: SUPER_TOGGLE_SEARCH, + key: 'f', + modifiers: [primaryModifier], + }, + { + command: SUPER_SEARCH_TOGGLE_REPLACE_MODE, + key: 'h', + modifiers: [primaryModifier], + }, + { + command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE, + key: 'c', + modifiers: [KeyboardModifier.Alt], + }, + { + command: SUPER_SEARCH_NEXT_RESULT, + key: 'F3', + }, + { + command: SUPER_SEARCH_PREVIOUS_RESULT, + key: 'F3', + modifiers: [KeyboardModifier.Shift], + }, { command: SUPER_SHOW_MARKDOWN_PREVIEW, key: 'm', diff --git a/packages/utils/src/Domain/Utils/Debounce.ts b/packages/utils/src/Domain/Utils/Debounce.ts new file mode 100644 index 000000000..3efe896f0 --- /dev/null +++ b/packages/utils/src/Domain/Utils/Debounce.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * MIT License + +Copyright (c) 2017 Jakub Chodorowicz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +export type Options = { + isImmediate?: boolean + maxWait?: number + callback?: (data: Result) => void +} + +export interface DebouncedFunction any> { + (this: ThisParameterType, ...args: Args & Parameters): Promise> + cancel: (reason?: any) => void +} + +interface DebouncedPromise { + resolve: (result: FunctionReturn) => void + reject: (reason?: any) => void +} + +export function debounce any>( + func: F, + waitMilliseconds = 50, + options: Options> = {}, +): DebouncedFunction { + let timeoutId: ReturnType | undefined + const isImmediate = options.isImmediate ?? false + const callback = options.callback ?? false + const maxWait = options.maxWait + let lastInvokeTime = Date.now() + + let promises: DebouncedPromise>[] = [] + + function nextInvokeTimeout() { + if (maxWait !== undefined) { + const timeSinceLastInvocation = Date.now() - lastInvokeTime + + if (timeSinceLastInvocation + waitMilliseconds >= maxWait) { + return maxWait - timeSinceLastInvocation + } + } + + return waitMilliseconds + } + + const debouncedFunction = function (this: ThisParameterType, ...args: Parameters) { + // eslint-disable-next-line no-invalid-this, @typescript-eslint/no-this-alias + const context = this + return new Promise>((resolve, reject) => { + const invokeFunction = function () { + timeoutId = undefined + lastInvokeTime = Date.now() + if (!isImmediate) { + const result = func.apply(context, args) + callback && callback(result) + promises.forEach(({ resolve }) => resolve(result)) + promises = [] + } + } + + const shouldCallNow = isImmediate && timeoutId === undefined + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + timeoutId = setTimeout(invokeFunction, nextInvokeTimeout()) + + if (shouldCallNow) { + const result = func.apply(context, args) + callback && callback(result) + return resolve(result) + } + promises.push({ resolve, reject }) + }) + } + + debouncedFunction.cancel = function (reason?: any) { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + promises.forEach(({ reject }) => reject(reason)) + promises = [] + } + + return debouncedFunction +} diff --git a/packages/utils/src/Domain/index.ts b/packages/utils/src/Domain/index.ts index 19418df3b..800cbd5a0 100644 --- a/packages/utils/src/Domain/index.ts +++ b/packages/utils/src/Domain/index.ts @@ -2,6 +2,7 @@ export * from './Date/DateUtils' export * from './Deferred/Deferred' export * from './Utils/ClassNames' export * from './Utils/Utils' +export * from './Utils/Debounce' export * from './Uuid/Utils' export * from './Uuid/UuidGenerator' export * from './Uuid/UuidMap' diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx index abdf0368b..d94a02d8b 100644 --- a/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite' import { useCallback, useReducer, useState } from 'react' import { useApplication } from '../ApplicationProvider' import Button from '../Button/Button' -import { useStateRef } from '../Panes/useStateRef' +import { useStateRef } from '@/Hooks/useStateRef' import ModalDialog from '../Shared/ModalDialog' import ModalDialogButtons from '../Shared/ModalDialogButtons' import ModalDialogDescription from '../Shared/ModalDialogDescription' diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Context.tsx b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Context.tsx new file mode 100644 index 000000000..82a757169 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Context.tsx @@ -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 + 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/NoteView/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx new file mode 100644 index 000000000..9aa16b546 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/SearchDialog.tsx @@ -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 ( +
+ +
{ + if (event.key === 'Escape') { + closeDialog() + } + }} + > +
+ { + 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 ? ( + + {currentResultIndex > -1 ? currentResultIndex + 1 + ' of ' : null} + {results.length} + + ) : ( + No results + )} + + + + +
+ {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/NoteView/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx new file mode 100644 index 000000000..9f1e5c358 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/SearchPlugin.tsx @@ -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 ( + <> + { + dispatch({ type: 'toggle-search' }) + dispatch({ type: 'reset-search' }) + editor.focus() + }} + /> + + ) +} diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Types.ts b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Types.ts new file mode 100644 index 000000000..daf776a94 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/Types.ts @@ -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 +} diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts new file mode 100644 index 000000000..677cb70b0 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/createSearchHighlightElement.ts @@ -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) + }) +} diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts new file mode 100644 index 000000000..3e450b946 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/Plugins/SearchPlugin/getAllTextNodesInElement.ts @@ -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 +} diff --git a/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx index 46e280625..a29eef32c 100644 --- a/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx +++ b/packages/web/src/javascripts/Components/NoteView/SuperEditor/SuperEditor.tsx @@ -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 = ({ return (
- <> - - - + + + - - - - - - - - - - (changeEditorFunction.current = callback)} - /> - - - - - {controller.isTemplateNote ? : null} - - - - - - {showMarkdownPreview && } - + + + + + + + + + (changeEditorFunction.current = callback)} + /> + + + + + {controller.isTemplateNote ? : null} + + + + + + + + {showMarkdownPreview && }
) diff --git a/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx b/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx index 41864f6f6..05e2d9271 100644 --- a/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx +++ b/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx @@ -4,7 +4,7 @@ import { AppPaneId } from './AppPaneMetadata' import { PaneController } from '../../Controllers/PaneController/PaneController' import { observer } from 'mobx-react-lite' import { PaneLayout } from '@/Controllers/PaneController/PaneLayout' -import { useStateRef } from './useStateRef' +import { useStateRef } from '@/Hooks/useStateRef' type ResponsivePaneData = { selectedPane: AppPaneId diff --git a/packages/web/src/javascripts/Constants/AnimationConfigs.ts b/packages/web/src/javascripts/Constants/AnimationConfigs.ts new file mode 100644 index 000000000..d7122675b --- /dev/null +++ b/packages/web/src/javascripts/Constants/AnimationConfigs.ts @@ -0,0 +1,131 @@ +export type AnimationConfig = { + keyframes: Keyframe[] + options: KeyframeAnimationOptions + initialStyle?: Partial +} + +export const EnterFromTopAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 0, + transform: 'scaleY(0)', + }, + { + opacity: 1, + transform: 'scaleY(1)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'top', + }, +} + +export const EnterFromBelowAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 0, + transform: 'scaleY(0)', + }, + { + opacity: 1, + transform: 'scaleY(1)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'bottom', + }, +} + +export const ExitToTopAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 1, + transform: 'scaleY(1)', + }, + { + opacity: 0, + transform: 'scaleY(0)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'top', + }, +} + +export const ExitToBelowAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 1, + transform: 'scaleY(1)', + }, + { + opacity: 0, + transform: 'scaleY(0)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'bottom', + }, +} + +export const TranslateFromTopAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 0, + transform: 'translateY(-100%)', + }, + { + opacity: 1, + transform: 'translateY(0)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'top', + }, +} + +export const TranslateToTopAnimation: AnimationConfig = { + keyframes: [ + { + opacity: 1, + transform: 'translateY(0)', + }, + { + opacity: 0, + transform: 'translateY(-100%)', + }, + ], + options: { + easing: 'ease-in-out', + duration: 150, + fill: 'forwards', + }, + initialStyle: { + transformOrigin: 'top', + }, +} diff --git a/packages/web/src/javascripts/Hooks/useLifecycleAnimation.ts b/packages/web/src/javascripts/Hooks/useLifecycleAnimation.ts new file mode 100644 index 000000000..176590cac --- /dev/null +++ b/packages/web/src/javascripts/Hooks/useLifecycleAnimation.ts @@ -0,0 +1,94 @@ +import { RefCallback, useEffect, useState } from 'react' +import { AnimationConfig } from '../Constants/AnimationConfigs' +import { useStateRef } from './useStateRef' + +type Options = { + open: boolean + enter: AnimationConfig + enterCallback?: (element: HTMLElement) => void + exit: AnimationConfig + exitCallback?: (element: HTMLElement) => void +} + +/** + * A hook that animates an element when it mounts and unmounts. + * Does not handle DOM insertion/removal. Use the `isMounted` return value to conditionally render the element. + * @param open Whether the element is open or not + * @param enter The animation to play when the element mounts + * @param enterCallback A callback to run after the enter animation finishes + * @param exit The animation to play when the element unmounts + * @param exitCallback A callback to run after the exit animation finishes + * @returns A tuple containing whether the element can be mounted and a ref callback to set the element + */ +export const useLifecycleAnimation = ({ + open, + enter, + enterCallback, + exit, + exitCallback, +}: Options): [boolean, RefCallback] => { + const [element, setElement] = useState(null) + + const [isMounted, setIsMounted] = useState(() => open) + useEffect(() => { + if (open) { + setIsMounted(open) + } + }, [open]) + + // Using "state ref"s to prevent changes from re-running the effect below + // We only want changes to `open` and `element` to re-run the effect + const enterRef = useStateRef(enter) + const enterCallbackRef = useStateRef(enterCallback) + const exitRef = useStateRef(exit) + const exitCallbackRef = useStateRef(exitCallback) + + useEffect(() => { + if (!element) { + return + } + + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + + if (prefersReducedMotion) { + setIsMounted(open) + return + } + + const enter = enterRef.current + const enterCallback = enterCallbackRef.current + const exit = exitRef.current + const exitCallback = exitCallbackRef.current + + if (open) { + if (enter.initialStyle) { + Object.assign(element.style, enter.initialStyle) + } + const animation = element.animate(enter.keyframes, { + ...enter.options, + fill: 'forwards', + }) + animation.finished + .then(() => { + enterCallback?.(element) + }) + .catch(console.error) + } else { + if (exit.initialStyle) { + Object.assign(element.style, exit.initialStyle) + } + const animation = element.animate(exit.keyframes, { + ...exit.options, + fill: 'forwards', + }) + animation.finished + .then(() => { + setIsMounted(false) + exitCallback?.(element) + }) + .catch(console.error) + } + }, [open, element, enterRef, enterCallbackRef, exitRef, exitCallbackRef]) + + return [isMounted, setElement] +} diff --git a/packages/web/src/javascripts/Components/Panes/useStateRef.tsx b/packages/web/src/javascripts/Hooks/useStateRef.ts similarity index 100% rename from packages/web/src/javascripts/Components/Panes/useStateRef.tsx rename to packages/web/src/javascripts/Hooks/useStateRef.ts