feat: Added search and replace to Super notes on web/desktop. Press Ctrl+F in a super note to toggle search. (skip e2e) (#2128)

This commit is contained in:
Aman Harwara
2023-01-12 18:57:41 +05:30
committed by GitHub
parent 2fc365434f
commit 8104522658
21 changed files with 1180 additions and 45 deletions

View File

@@ -97,12 +97,13 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller h-full">
<div className="editor" ref={onRef}>
<div className="editor z-0 overflow-hidden" ref={onRef}>
<ContentEditable
id={SuperEditorContentId}
className={classNames('ContentEditable__root overflow-y-auto', className)}
spellCheck={spellcheck}
/>
<div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" />
</div>
</div>
}

View File

@@ -443,7 +443,7 @@ function useDraggableBlockMenu(editor: LexicalEditor, anchorElem: HTMLElement, i
onTouchEnd={onTouchEnd}
>
<div className={isEditable ? 'icon' : ''}>
<BlockIcon className="text-text pointer-events-none" />
<BlockIcon className="pointer-events-none text-text" />
</div>
</div>
<div className="draggable-block-target-line" ref={targetLineRef} />

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd"
d="M11.6 2.677c.147-.31.356-.465.626-.465c.248 0 .44.118.573.353c.134.236.201.557.201.966c0 .443-.078.798-.235 1.067c-.156.268-.365.402-.627.402c-.237 0-.416-.125-.537-.374h-.008v.31H11V1h.593v1.677h.008zm-.016 1.1a.78.78 0 0 0 .107.426c.071.113.163.169.274.169c.136 0 .24-.072.314-.216c.075-.145.113-.35.113-.615c0-.22-.035-.39-.104-.514c-.067-.124-.164-.187-.29-.187c-.12 0-.219.062-.297.185a.886.886 0 0 0-.117.48v.272zM4.12 7.695L2 5.568l.662-.662l1.006 1v-1.51A1.39 1.39 0 0 1 5.055 3H7.4v.905H5.055a.49.49 0 0 0-.468.493l.007 1.5l.949-.944l.656.656l-2.08 2.085zM9.356 4.93H10V3.22C10 2.408 9.685 2 9.056 2c-.135 0-.285.024-.45.073a1.444 1.444 0 0 0-.388.167v.665c.237-.203.487-.304.75-.304c.261 0 .392.156.392.469l-.6.103c-.506.086-.76.406-.76.961c0 .263.061.473.183.631A.61.61 0 0 0 8.69 5c.29 0 .509-.16.657-.48h.009v.41zm.004-1.355v.193a.75.75 0 0 1-.12.436a.368.368 0 0 1-.313.17a.276.276 0 0 1-.22-.095a.38.38 0 0 1-.08-.248c0-.222.11-.351.332-.389l.4-.067zM7 12.93h-.644v-.41h-.009c-.148.32-.367.48-.657.48a.61.61 0 0 1-.507-.235c-.122-.158-.183-.368-.183-.63c0-.556.254-.876.76-.962l.6-.103c0-.313-.13-.47-.392-.47c-.263 0-.513.102-.75.305v-.665c.095-.063.224-.119.388-.167c.165-.049.315-.073.45-.073c.63 0 .944.407.944 1.22v1.71zm-.64-1.162v-.193l-.4.068c-.222.037-.333.166-.333.388c0 .1.027.183.08.248a.276.276 0 0 0 .22.095a.368.368 0 0 0 .312-.17c.08-.116.12-.26.12-.436zM9.262 13c.321 0 .568-.058.738-.173v-.71a.9.9 0 0 1-.552.207a.619.619 0 0 1-.5-.215c-.12-.145-.181-.345-.181-.598c0-.26.063-.464.189-.612a.644.644 0 0 1 .516-.223c.194 0 .37.069.528.207v-.749c-.129-.09-.338-.134-.626-.134c-.417 0-.751.14-1.001.422c-.249.28-.373.662-.373 1.148c0 .42.116.764.349 1.03c.232.267.537.4.913.4zM2 9l1-1h9l1 1v5l-1 1H3l-1-1V9zm1 0v5h9V9H3zm3-2l1-1h7l1 1v5l-1 1V7H6z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16">
<path fill="currentColor" fill-rule="evenodd"
d="m3.221 3.739l2.261 2.269L7.7 3.784l-.7-.7l-1.012 1.007l-.008-1.6a.523.523 0 0 1 .5-.526H8V1H6.48A1.482 1.482 0 0 0 5 2.489V4.1L3.927 3.033l-.706.706zm6.67 1.794h.01c.183.311.451.467.806.467c.393 0 .706-.168.94-.503c.236-.335.353-.78.353-1.333c0-.511-.1-.913-.301-1.207c-.201-.295-.488-.442-.86-.442c-.405 0-.718.194-.938.581h-.01V1H9v4.919h.89v-.386zm-.015-1.061v-.34c0-.248.058-.448.175-.601a.54.54 0 0 1 .445-.23a.49.49 0 0 1 .436.233c.104.154.155.368.155.643c0 .33-.056.587-.169.768a.524.524 0 0 1-.47.27a.495.495 0 0 1-.411-.211a.853.853 0 0 1-.16-.532zM9 12.769c-.256.154-.625.231-1.108.231c-.563 0-1.02-.178-1.369-.533c-.349-.355-.523-.813-.523-1.374c0-.648.186-1.158.56-1.53c.374-.376.875-.563 1.5-.563c.433 0 .746.06.94.179v.998a1.26 1.26 0 0 0-.792-.276c-.325 0-.583.1-.774.298c-.19.196-.283.468-.283.816c0 .338.09.603.272.797c.182.191.431.287.749.287c.282 0 .558-.092.828-.276v.946zM4 7L3 8v6l1 1h7l1-1V8l-1-1H4zm0 1h7v6H4V8z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

View File

@@ -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',

View File

@@ -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<Result> = {
isImmediate?: boolean
maxWait?: number
callback?: (data: Result) => void
}
export interface DebouncedFunction<Args extends any[], F extends (...args: Args) => any> {
(this: ThisParameterType<F>, ...args: Args & Parameters<F>): Promise<ReturnType<F>>
cancel: (reason?: any) => void
}
interface DebouncedPromise<FunctionReturn> {
resolve: (result: FunctionReturn) => void
reject: (reason?: any) => void
}
export function debounce<Args extends any[], F extends (...args: Args) => any>(
func: F,
waitMilliseconds = 50,
options: Options<ReturnType<F>> = {},
): DebouncedFunction<Args, F> {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const isImmediate = options.isImmediate ?? false
const callback = options.callback ?? false
const maxWait = options.maxWait
let lastInvokeTime = Date.now()
let promises: DebouncedPromise<ReturnType<F>>[] = []
function nextInvokeTimeout() {
if (maxWait !== undefined) {
const timeSinceLastInvocation = Date.now() - lastInvokeTime
if (timeSinceLastInvocation + waitMilliseconds >= maxWait) {
return maxWait - timeSinceLastInvocation
}
}
return waitMilliseconds
}
const debouncedFunction = function (this: ThisParameterType<F>, ...args: Parameters<F>) {
// eslint-disable-next-line no-invalid-this, @typescript-eslint/no-this-alias
const context = this
return new Promise<ReturnType<F>>((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
}

View File

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

View File

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

View File

@@ -0,0 +1,130 @@
import { createContext, ReactNode, useCallback, useContext, useMemo, useReducer, useRef } from 'react'
import { SuperSearchContextAction, SuperSearchContextState, SuperSearchReplaceEvent } from './Types'
type SuperSearchContextData = SuperSearchContextState & {
dispatch: React.Dispatch<SuperSearchContextAction>
addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void
dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void
}
const SuperSearchContext = createContext<SuperSearchContextData | undefined>(undefined)
export const useSuperSearchContext = () => {
const context = useContext(SuperSearchContext)
if (!context) {
throw new Error('useSuperSearchContext must be used within a SuperSearchContextProvider')
}
return context
}
const initialState: SuperSearchContextState = {
query: '',
results: [],
currentResultIndex: -1,
isCaseSensitive: false,
isSearchActive: false,
isReplaceMode: false,
}
const searchContextReducer = (
state: SuperSearchContextState,
action: SuperSearchContextAction,
): SuperSearchContextState => {
switch (action.type) {
case 'set-query':
return {
...state,
query: action.query,
}
case 'set-results':
return {
...state,
results: action.results,
currentResultIndex: action.results.length > 0 ? 0 : -1,
}
case 'clear-results':
return {
...state,
results: [],
currentResultIndex: -1,
}
case 'set-current-result-index':
return {
...state,
currentResultIndex: action.index,
}
case 'toggle-search':
return {
...initialState,
isSearchActive: !state.isSearchActive,
}
case 'toggle-case-sensitive':
return {
...state,
isCaseSensitive: !state.isCaseSensitive,
}
case 'toggle-replace-mode': {
const toggledValue = !state.isReplaceMode
return {
...state,
isSearchActive: toggledValue && !state.isSearchActive ? true : state.isSearchActive,
isReplaceMode: toggledValue,
}
}
case 'go-to-next-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex + 1 < state.results.length
? state.currentResultIndex + 1
: 0,
}
case 'go-to-previous-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex - 1 >= 0
? state.currentResultIndex - 1
: state.results.length - 1,
}
case 'reset-search':
return { ...initialState }
}
}
export const SuperSearchContextProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(searchContextReducer, initialState)
const replaceEventListeners = useRef(new Set<(type: SuperSearchReplaceEvent) => void>())
const addReplaceEventListener = useCallback((listener: (type: SuperSearchReplaceEvent) => void) => {
replaceEventListeners.current.add(listener)
return () => {
replaceEventListeners.current.delete(listener)
}
}, [])
const dispatchReplaceEvent = useCallback((type: SuperSearchReplaceEvent) => {
replaceEventListeners.current.forEach((listener) => listener(type))
}, [])
const value = useMemo(
() => ({
...state,
dispatch,
addReplaceEventListener,
dispatchReplaceEvent,
}),
[addReplaceEventListener, dispatchReplaceEvent, state],
)
return <SuperSearchContext.Provider value={value}>{children}</SuperSearchContext.Provider>
}

View File

@@ -0,0 +1,232 @@
import { useCommandService } from '@/Components/CommandProvider'
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import {
ArrowDownIcon,
ArrowUpIcon,
CloseIcon,
ReplaceIcon,
ReplaceAllIcon,
ArrowRightIcon,
} from '@standardnotes/icons'
import {
keyboardStringForShortcut,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
} from '@standardnotes/ui-services'
import { classNames } from '@standardnotes/utils'
import { useCallback, useMemo, useState } from 'react'
import { useSuperSearchContext } from './Context'
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
const { query, results, currentResultIndex, isCaseSensitive, isReplaceMode, dispatch, dispatchReplaceEvent } =
useSuperSearchContext()
const [replaceQuery, setReplaceQuery] = useState('')
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
}
}, [])
const [isMounted, setElement] = useLifecycleAnimation({
open,
enter: TranslateFromTopAnimation,
exit: TranslateToTopAnimation,
})
const commandService = useCommandService()
const searchToggleShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
[commandService],
)
const toggleReplaceShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
[commandService],
)
const caseSensitivityShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
[commandService],
)
if (!isMounted) {
return null
}
return (
<div
className="absolute right-6 top-4 z-10 flex select-none rounded border border-border bg-default"
ref={setElement}
>
<button
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
onClick={() => {
dispatch({ type: 'toggle-replace-mode' })
}}
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
>
{isReplaceMode ? (
<ArrowDownIcon className="h-4 w-4 fill-text" />
) : (
<ArrowRightIcon className="h-4 w-4 fill-text" />
)}
</button>
<div
className="flex flex-col gap-2 py-2 px-2"
onKeyDown={(event) => {
if (event.key === 'Escape') {
closeDialog()
}
}}
>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Search"
value={query}
onChange={(e) => {
dispatch({
type: 'set-query',
query: e.target.value,
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && results.length) {
if (event.shiftKey) {
dispatch({
type: 'go-to-previous-result',
})
return
}
dispatch({
type: 'go-to-next-result',
})
}
}}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
/>
{results.length > 0 ? (
<span className="min-w-[10ch] text-text">
{currentResultIndex > -1 ? currentResultIndex + 1 + ' of ' : null}
{results.length}
</span>
) : (
<span className="min-w-[10ch] text-text">No results</span>
)}
<label
className={classNames(
'relative flex items-center rounded border py-1 px-1.5 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
)}
title={`Case sensitive (${caseSensitivityShortcut})`}
>
<input
type="checkbox"
className="absolute top-0 left-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
checked={isCaseSensitive}
onChange={() => {
dispatch({
type: 'toggle-case-sensitive',
})
}}
/>
<span aria-hidden>Aa</span>
<span className="sr-only">Case sensitive</span>
</label>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-previous-result',
})
}}
disabled={results.length < 1}
title="Previous result (Shift + Enter)"
>
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-next-result',
})
}}
disabled={results.length < 1}
title="Next result (Enter)"
>
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
onClick={() => {
closeDialog()
}}
title={`Close (${searchToggleShortcut})`}
>
<CloseIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
{isReplaceMode && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Replace"
onChange={(e) => {
setReplaceQuery(e.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && replaceQuery && results.length) {
if (event.ctrlKey && event.altKey) {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
event.preventDefault()
return
}
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
event.preventDefault()
}
}}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
/>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace (Ctrl + Enter)"
>
<ReplaceIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace all (Ctrl + Alt + Enter)"
>
<ReplaceAllIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,305 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getNearestNodeFromDOMNode, TextNode } from 'lexical'
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import { createSearchHighlightElement } from './createSearchHighlightElement'
import { useSuperSearchContext } from './Context'
import { SearchDialog } from './SearchDialog'
import { getAllTextNodesInElement } from './getAllTextNodesInElement'
import { SuperSearchResult } from './Types'
import { debounce } from '@standardnotes/utils'
import { useApplication } from '@/Components/ApplicationProvider'
import {
SUPER_SEARCH_NEXT_RESULT,
SUPER_SEARCH_PREVIOUS_RESULT,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
} from '@standardnotes/ui-services'
import { useStateRef } from '@/Hooks/useStateRef'
export const SearchPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
useSuperSearchContext()
const queryRef = useStateRef(query)
const currentResultIndexRef = useStateRef(currentResultIndex)
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
const resultsRef = useStateRef(results)
useEffect(() => {
if (!isSearchActive) {
editor.focus()
}
}, [editor, isSearchActive])
useEffect(() => {
const isFocusInEditor = () => {
if (!document.activeElement || !document.activeElement.closest('.blocks-editor')) {
return false
}
return true
}
return application.keyboardService.addCommandHandlers([
{
command: SUPER_TOGGLE_SEARCH,
onKeyDown: (event) => {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-search' })
},
},
{
command: SUPER_SEARCH_TOGGLE_REPLACE_MODE,
onKeyDown: (event) => {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-replace-mode' })
},
},
{
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
onKeyDown() {
if (!isFocusInEditor()) {
return
}
dispatch({
type: 'toggle-case-sensitive',
})
},
},
{
command: SUPER_SEARCH_NEXT_RESULT,
onKeyDown(event) {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-next-result',
})
},
},
{
command: SUPER_SEARCH_PREVIOUS_RESULT,
onKeyDown(event) {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-previous-result',
})
},
},
])
}, [application.keyboardService, dispatch, editor])
const handleSearch = useCallback(
(query: string, isCaseSensitive: boolean) => {
document.querySelectorAll('.search-highlight').forEach((element) => {
element.remove()
})
if (!query) {
dispatch({ type: 'clear-results' })
return
}
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
if (!rootElement) {
return
}
const textNodes = getAllTextNodesInElement(rootElement)
const results: SuperSearchResult[] = []
textNodes.forEach((node) => {
const text = node.textContent || ''
const indices: number[] = []
let index = -1
const textWithCase = isCaseSensitive ? text : text.toLowerCase()
const queryWithCase = isCaseSensitive ? query : query.toLowerCase()
while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) {
indices.push(index)
}
indices.forEach((index) => {
const startIndex = index
const endIndex = startIndex + query.length
results.push({
node,
startIndex,
endIndex,
})
})
})
dispatch({
type: 'set-results',
results,
})
})
},
[dispatch, editor],
)
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
useEffect(() => {
if (!query) {
dispatch({ type: 'clear-results' })
dispatch({ type: 'set-current-result-index', index: -1 })
return
}
void handleQueryChange(query, isCaseSensitiveRef.current)
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
useEffect(() => {
const handleCaseSensitiveChange = () => {
void handleSearch(queryRef.current, isCaseSensitive)
}
handleCaseSensitiveChange()
}, [handleSearch, isCaseSensitive, queryRef])
useLayoutEffect(() => {
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
if (
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
tags.has('history-merge') ||
prevEditorState.isEmpty()
) {
return
}
void handleEditorChange(queryRef.current, isCaseSensitiveRef.current)
})
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
useEffect(() => {
return addReplaceEventListener((event) => {
const { replace, type } = event
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
const { node, startIndex, endIndex } = result
const lexicalNode = $getNearestNodeFromDOMNode(node)
if (!lexicalNode) {
return
}
if (lexicalNode instanceof TextNode) {
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
}
if (scrollIntoView && node.parentElement) {
node.parentElement.scrollIntoView({
block: 'center',
})
}
}
editor.update(() => {
if (type === 'next') {
const result = resultsRef.current[currentResultIndexRef.current]
if (!result) {
return
}
replaceResult(result, true)
} else if (type === 'all') {
resultsRef.current.forEach((result) => replaceResult(result))
}
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
})
})
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
useEffect(() => {
document.querySelectorAll('.search-highlight').forEach((element) => {
element.remove()
})
if (currentResultIndex === -1) {
return
}
const result = results[currentResultIndex]
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
const containerElement = rootElement?.parentElement?.getElementsByClassName('search-highlight-container')[0]
result.node.parentElement?.scrollIntoView({
block: 'center',
})
if (!rootElement || !containerElement) {
return
}
createSearchHighlightElement(result, rootElement, containerElement)
})
}, [currentResultIndex, editor, results])
useEffect(() => {
let containerElement: HTMLElement | null | undefined
let rootElement: HTMLElement | null | undefined
editor.getEditorState().read(() => {
rootElement = editor.getRootElement()
containerElement = rootElement?.parentElement?.querySelector('.search-highlight-container')
})
if (!rootElement || !containerElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.height = `${rootElement.scrollHeight}px`
containerElement.style.overflow = 'visible'
})
resizeObserver.observe(rootElement)
const handleScroll = () => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.top = `-${rootElement.scrollTop}px`
}
rootElement.addEventListener('scroll', handleScroll)
return () => {
resizeObserver.disconnect()
rootElement?.removeEventListener('scroll', handleScroll)
}
}, [editor])
return (
<>
<SearchDialog
open={isSearchActive}
closeDialog={() => {
dispatch({ type: 'toggle-search' })
dispatch({ type: 'reset-search' })
editor.focus()
}}
/>
</>
)
}

View File

@@ -0,0 +1,31 @@
export type SuperSearchResult = {
node: Text
startIndex: number
endIndex: number
}
export type SuperSearchContextState = {
query: string
results: SuperSearchResult[]
currentResultIndex: number
isCaseSensitive: boolean
isSearchActive: boolean
isReplaceMode: boolean
}
export type SuperSearchContextAction =
| { type: 'set-query'; query: string }
| { type: 'set-results'; results: SuperSearchResult[] }
| { type: 'clear-results' }
| { type: 'set-current-result-index'; index: number }
| { type: 'go-to-next-result' }
| { type: 'go-to-previous-result' }
| { type: 'toggle-case-sensitive' }
| { type: 'toggle-replace-mode' }
| { type: 'toggle-search' }
| { type: 'reset-search' }
export type SuperSearchReplaceEvent = {
type: 'next' | 'all'
replace: string
}

View File

@@ -0,0 +1,40 @@
import { SuperSearchResult } from './Types'
export const createSearchHighlightElement = (
result: SuperSearchResult,
rootElement: Element,
containerElement: Element,
) => {
const rootElementRect = rootElement.getBoundingClientRect()
const range = document.createRange()
range.setStart(result.node, result.startIndex)
range.setEnd(result.node, result.endIndex)
const rects = range.getClientRects()
Array.from(rects).forEach((rect, index) => {
const id = `search-${result.startIndex}-${result.endIndex}-${index}`
const existingHighlightElement = document.getElementById(id)
if (existingHighlightElement) {
return
}
const highlightElement = document.createElement('div')
highlightElement.style.position = 'absolute'
highlightElement.style.zIndex = '1000'
highlightElement.style.transform = `translate(${rect.left - rootElementRect.left}px, ${
rect.top - rootElementRect.top + rootElement.scrollTop
}px)`
highlightElement.style.width = `${rect.width}px`
highlightElement.style.height = `${rect.height}px`
highlightElement.style.backgroundColor = 'var(--sn-stylekit-info-color)'
highlightElement.style.opacity = '0.5'
highlightElement.className = 'search-highlight'
highlightElement.id = id
containerElement.appendChild(highlightElement)
})
}

View File

@@ -0,0 +1,10 @@
export const getAllTextNodesInElement = (element: HTMLElement) => {
const textNodes: Text[] = []
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
let node = walk.nextNode()
while (node) {
textNodes.push(node as Text)
node = walk.nextNode()
}
return textNodes
}

View File

@@ -38,6 +38,8 @@ import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMark
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
const NotePreviewCharLimit = 160
@@ -162,48 +164,48 @@ export const SuperEditor: FunctionComponent<Props> = ({
return (
<div className="font-editor relative h-full w-full">
<ErrorBoundary>
<>
<LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}>
<BlocksEditorComposer
readonly={note.current.locked}
initialValue={note.current.text}
nodes={[FileNode, BubbleNode]}
<LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}>
<BlocksEditorComposer
readonly={note.current.locked}
initialValue={note.current.text}
nodes={[FileNode, BubbleNode]}
>
<BlocksEditor
onChange={handleChange}
ignoreFirstChange={controller.isTemplateNote}
className={classNames(
'blocks-editor relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
)}
previewLength={NotePreviewCharLimit}
spellcheck={spellcheck}
>
<BlocksEditor
onChange={handleChange}
ignoreFirstChange={controller.isTemplateNote}
className={classNames(
'relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
)}
previewLength={NotePreviewCharLimit}
spellcheck={spellcheck}
>
<ItemSelectionPlugin currentNote={note.current} />
<FilePlugin />
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<GetMarkdownPlugin ref={getMarkdownPlugin} />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<ChangeContentCallbackPlugin
providerCallback={(callback) => (changeEditorFunction.current = callback)}
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
<ReadonlyPlugin note={note.current} />
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
</>
<ItemSelectionPlugin currentNote={note.current} />
<FilePlugin />
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<GetMarkdownPlugin ref={getMarkdownPlugin} />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<ChangeContentCallbackPlugin
providerCallback={(callback) => (changeEditorFunction.current = callback)}
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
<ReadonlyPlugin note={note.current} />
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
<SuperSearchContextProvider>
<SearchPlugin />
</SuperSearchContextProvider>
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
</ErrorBoundary>
</div>
)

View File

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

View File

@@ -0,0 +1,131 @@
export type AnimationConfig = {
keyframes: Keyframe[]
options: KeyframeAnimationOptions
initialStyle?: Partial<CSSStyleDeclaration>
}
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',
},
}

View File

@@ -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<HTMLElement | null>] => {
const [element, setElement] = useState<HTMLElement | null>(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]
}