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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
5
packages/icons/src/Icons/ic-replace-all.svg
Normal file
5
packages/icons/src/Icons/ic-replace-all.svg
Normal 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 |
5
packages/icons/src/Icons/ic-replace.svg
Normal file
5
packages/icons/src/Icons/ic-replace.svg
Normal 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 |
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
108
packages/utils/src/Domain/Utils/Debounce.ts
Normal file
108
packages/utils/src/Domain/Utils/Debounce.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createContext, ReactNode, useCallback, useContext, useMemo, useReducer, useRef } from 'react'
|
||||
import { SuperSearchContextAction, SuperSearchContextState, SuperSearchReplaceEvent } from './Types'
|
||||
|
||||
type SuperSearchContextData = SuperSearchContextState & {
|
||||
dispatch: React.Dispatch<SuperSearchContextAction>
|
||||
addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void
|
||||
dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void
|
||||
}
|
||||
|
||||
const SuperSearchContext = createContext<SuperSearchContextData | undefined>(undefined)
|
||||
|
||||
export const useSuperSearchContext = () => {
|
||||
const context = useContext(SuperSearchContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSuperSearchContext must be used within a SuperSearchContextProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const initialState: SuperSearchContextState = {
|
||||
query: '',
|
||||
results: [],
|
||||
currentResultIndex: -1,
|
||||
isCaseSensitive: false,
|
||||
isSearchActive: false,
|
||||
isReplaceMode: false,
|
||||
}
|
||||
|
||||
const searchContextReducer = (
|
||||
state: SuperSearchContextState,
|
||||
action: SuperSearchContextAction,
|
||||
): SuperSearchContextState => {
|
||||
switch (action.type) {
|
||||
case 'set-query':
|
||||
return {
|
||||
...state,
|
||||
query: action.query,
|
||||
}
|
||||
case 'set-results':
|
||||
return {
|
||||
...state,
|
||||
results: action.results,
|
||||
currentResultIndex: action.results.length > 0 ? 0 : -1,
|
||||
}
|
||||
case 'clear-results':
|
||||
return {
|
||||
...state,
|
||||
results: [],
|
||||
currentResultIndex: -1,
|
||||
}
|
||||
case 'set-current-result-index':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex: action.index,
|
||||
}
|
||||
case 'toggle-search':
|
||||
return {
|
||||
...initialState,
|
||||
isSearchActive: !state.isSearchActive,
|
||||
}
|
||||
case 'toggle-case-sensitive':
|
||||
return {
|
||||
...state,
|
||||
isCaseSensitive: !state.isCaseSensitive,
|
||||
}
|
||||
case 'toggle-replace-mode': {
|
||||
const toggledValue = !state.isReplaceMode
|
||||
|
||||
return {
|
||||
...state,
|
||||
isSearchActive: toggledValue && !state.isSearchActive ? true : state.isSearchActive,
|
||||
isReplaceMode: toggledValue,
|
||||
}
|
||||
}
|
||||
case 'go-to-next-result':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex:
|
||||
state.results.length < 1
|
||||
? -1
|
||||
: state.currentResultIndex + 1 < state.results.length
|
||||
? state.currentResultIndex + 1
|
||||
: 0,
|
||||
}
|
||||
case 'go-to-previous-result':
|
||||
return {
|
||||
...state,
|
||||
currentResultIndex:
|
||||
state.results.length < 1
|
||||
? -1
|
||||
: state.currentResultIndex - 1 >= 0
|
||||
? state.currentResultIndex - 1
|
||||
: state.results.length - 1,
|
||||
}
|
||||
case 'reset-search':
|
||||
return { ...initialState }
|
||||
}
|
||||
}
|
||||
|
||||
export const SuperSearchContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(searchContextReducer, initialState)
|
||||
|
||||
const replaceEventListeners = useRef(new Set<(type: SuperSearchReplaceEvent) => void>())
|
||||
|
||||
const addReplaceEventListener = useCallback((listener: (type: SuperSearchReplaceEvent) => void) => {
|
||||
replaceEventListeners.current.add(listener)
|
||||
|
||||
return () => {
|
||||
replaceEventListeners.current.delete(listener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dispatchReplaceEvent = useCallback((type: SuperSearchReplaceEvent) => {
|
||||
replaceEventListeners.current.forEach((listener) => listener(type))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
dispatch,
|
||||
addReplaceEventListener,
|
||||
dispatchReplaceEvent,
|
||||
}),
|
||||
[addReplaceEventListener, dispatchReplaceEvent, state],
|
||||
)
|
||||
|
||||
return <SuperSearchContext.Provider value={value}>{children}</SuperSearchContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useCommandService } from '@/Components/CommandProvider'
|
||||
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
|
||||
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CloseIcon,
|
||||
ReplaceIcon,
|
||||
ReplaceAllIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@standardnotes/icons'
|
||||
import {
|
||||
keyboardStringForShortcut,
|
||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
SUPER_TOGGLE_SEARCH,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useSuperSearchContext } from './Context'
|
||||
|
||||
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
|
||||
const { query, results, currentResultIndex, isCaseSensitive, isReplaceMode, dispatch, dispatchReplaceEvent } =
|
||||
useSuperSearchContext()
|
||||
|
||||
const [replaceQuery, setReplaceQuery] = useState('')
|
||||
|
||||
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
|
||||
if (node) {
|
||||
node.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [isMounted, setElement] = useLifecycleAnimation({
|
||||
open,
|
||||
enter: TranslateFromTopAnimation,
|
||||
exit: TranslateToTopAnimation,
|
||||
})
|
||||
|
||||
const commandService = useCommandService()
|
||||
const searchToggleShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
|
||||
[commandService],
|
||||
)
|
||||
const toggleReplaceShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
|
||||
[commandService],
|
||||
)
|
||||
const caseSensitivityShortcut = useMemo(
|
||||
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
|
||||
[commandService],
|
||||
)
|
||||
|
||||
if (!isMounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute right-6 top-4 z-10 flex select-none rounded border border-border bg-default"
|
||||
ref={setElement}
|
||||
>
|
||||
<button
|
||||
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
|
||||
onClick={() => {
|
||||
dispatch({ type: 'toggle-replace-mode' })
|
||||
}}
|
||||
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
|
||||
>
|
||||
{isReplaceMode ? (
|
||||
<ArrowDownIcon className="h-4 w-4 fill-text" />
|
||||
) : (
|
||||
<ArrowRightIcon className="h-4 w-4 fill-text" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 px-2"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDialog()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
dispatch({
|
||||
type: 'set-query',
|
||||
query: e.target.value,
|
||||
})
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && results.length) {
|
||||
if (event.shiftKey) {
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="rounded border border-border bg-default p-1 px-2"
|
||||
ref={focusOnMount}
|
||||
/>
|
||||
{results.length > 0 ? (
|
||||
<span className="min-w-[10ch] text-text">
|
||||
{currentResultIndex > -1 ? currentResultIndex + 1 + ' of ' : null}
|
||||
{results.length}
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-[10ch] text-text">No results</span>
|
||||
)}
|
||||
<label
|
||||
className={classNames(
|
||||
'relative flex items-center rounded border py-1 px-1.5 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
|
||||
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
|
||||
)}
|
||||
title={`Case sensitive (${caseSensitivityShortcut})`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute top-0 left-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
|
||||
checked={isCaseSensitive}
|
||||
onChange={() => {
|
||||
dispatch({
|
||||
type: 'toggle-case-sensitive',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<span aria-hidden>Aa</span>
|
||||
<span className="sr-only">Case sensitive</span>
|
||||
</label>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1}
|
||||
title="Previous result (Shift + Enter)"
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1}
|
||||
title="Next result (Enter)"
|
||||
>
|
||||
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
|
||||
onClick={() => {
|
||||
closeDialog()
|
||||
}}
|
||||
title={`Close (${searchToggleShortcut})`}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
</div>
|
||||
{isReplaceMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Replace"
|
||||
onChange={(e) => {
|
||||
setReplaceQuery(e.target.value)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && replaceQuery && results.length) {
|
||||
if (event.ctrlKey && event.altKey) {
|
||||
dispatchReplaceEvent({
|
||||
type: 'all',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
dispatchReplaceEvent({
|
||||
type: 'next',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
className="rounded border border-border bg-default p-1 px-2"
|
||||
ref={focusOnMount}
|
||||
/>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'next',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace (Ctrl + Enter)"
|
||||
>
|
||||
<ReplaceIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
dispatchReplaceEvent({
|
||||
type: 'all',
|
||||
replace: replaceQuery,
|
||||
})
|
||||
}}
|
||||
disabled={results.length < 1 || replaceQuery.length < 1}
|
||||
title="Replace all (Ctrl + Alt + Enter)"
|
||||
>
|
||||
<ReplaceAllIcon className="h-4 w-4 fill-current text-text" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $getNearestNodeFromDOMNode, TextNode } from 'lexical'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||
import { createSearchHighlightElement } from './createSearchHighlightElement'
|
||||
import { useSuperSearchContext } from './Context'
|
||||
import { SearchDialog } from './SearchDialog'
|
||||
import { getAllTextNodesInElement } from './getAllTextNodesInElement'
|
||||
import { SuperSearchResult } from './Types'
|
||||
import { debounce } from '@standardnotes/utils'
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import {
|
||||
SUPER_SEARCH_NEXT_RESULT,
|
||||
SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
SUPER_TOGGLE_SEARCH,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { useStateRef } from '@/Hooks/useStateRef'
|
||||
|
||||
export const SearchPlugin = () => {
|
||||
const application = useApplication()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
|
||||
useSuperSearchContext()
|
||||
const queryRef = useStateRef(query)
|
||||
const currentResultIndexRef = useStateRef(currentResultIndex)
|
||||
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
|
||||
const resultsRef = useStateRef(results)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchActive) {
|
||||
editor.focus()
|
||||
}
|
||||
}, [editor, isSearchActive])
|
||||
|
||||
useEffect(() => {
|
||||
const isFocusInEditor = () => {
|
||||
if (!document.activeElement || !document.activeElement.closest('.blocks-editor')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return application.keyboardService.addCommandHandlers([
|
||||
{
|
||||
command: SUPER_TOGGLE_SEARCH,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-search' })
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_REPLACE_MODE,
|
||||
onKeyDown: (event) => {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle-replace-mode' })
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
|
||||
onKeyDown() {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
type: 'toggle-case-sensitive',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_NEXT_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-next-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: SUPER_SEARCH_PREVIOUS_RESULT,
|
||||
onKeyDown(event) {
|
||||
if (!isFocusInEditor()) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatch({
|
||||
type: 'go-to-previous-result',
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [application.keyboardService, dispatch, editor])
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(query: string, isCaseSensitive: boolean) => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
|
||||
if (!query) {
|
||||
dispatch({ type: 'clear-results' })
|
||||
return
|
||||
}
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
|
||||
if (!rootElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const textNodes = getAllTextNodesInElement(rootElement)
|
||||
|
||||
const results: SuperSearchResult[] = []
|
||||
|
||||
textNodes.forEach((node) => {
|
||||
const text = node.textContent || ''
|
||||
|
||||
const indices: number[] = []
|
||||
let index = -1
|
||||
|
||||
const textWithCase = isCaseSensitive ? text : text.toLowerCase()
|
||||
const queryWithCase = isCaseSensitive ? query : query.toLowerCase()
|
||||
|
||||
while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) {
|
||||
indices.push(index)
|
||||
}
|
||||
|
||||
indices.forEach((index) => {
|
||||
const startIndex = index
|
||||
const endIndex = startIndex + query.length
|
||||
|
||||
results.push({
|
||||
node,
|
||||
startIndex,
|
||||
endIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
dispatch({
|
||||
type: 'set-results',
|
||||
results,
|
||||
})
|
||||
})
|
||||
},
|
||||
[dispatch, editor],
|
||||
)
|
||||
|
||||
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
|
||||
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
dispatch({ type: 'clear-results' })
|
||||
dispatch({ type: 'set-current-result-index', index: -1 })
|
||||
return
|
||||
}
|
||||
|
||||
void handleQueryChange(query, isCaseSensitiveRef.current)
|
||||
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
|
||||
|
||||
useEffect(() => {
|
||||
const handleCaseSensitiveChange = () => {
|
||||
void handleSearch(queryRef.current, isCaseSensitive)
|
||||
}
|
||||
handleCaseSensitiveChange()
|
||||
}, [handleSearch, isCaseSensitive, queryRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
|
||||
if (
|
||||
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
|
||||
tags.has('history-merge') ||
|
||||
prevEditorState.isEmpty()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void handleEditorChange(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
|
||||
|
||||
useEffect(() => {
|
||||
return addReplaceEventListener((event) => {
|
||||
const { replace, type } = event
|
||||
|
||||
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
|
||||
const { node, startIndex, endIndex } = result
|
||||
const lexicalNode = $getNearestNodeFromDOMNode(node)
|
||||
if (!lexicalNode) {
|
||||
return
|
||||
}
|
||||
if (lexicalNode instanceof TextNode) {
|
||||
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
|
||||
}
|
||||
if (scrollIntoView && node.parentElement) {
|
||||
node.parentElement.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
if (type === 'next') {
|
||||
const result = resultsRef.current[currentResultIndexRef.current]
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
replaceResult(result, true)
|
||||
} else if (type === 'all') {
|
||||
resultsRef.current.forEach((result) => replaceResult(result))
|
||||
}
|
||||
|
||||
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
|
||||
})
|
||||
})
|
||||
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelectorAll('.search-highlight').forEach((element) => {
|
||||
element.remove()
|
||||
})
|
||||
if (currentResultIndex === -1) {
|
||||
return
|
||||
}
|
||||
const result = results[currentResultIndex]
|
||||
editor.getEditorState().read(() => {
|
||||
const rootElement = editor.getRootElement()
|
||||
const containerElement = rootElement?.parentElement?.getElementsByClassName('search-highlight-container')[0]
|
||||
result.node.parentElement?.scrollIntoView({
|
||||
block: 'center',
|
||||
})
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
createSearchHighlightElement(result, rootElement, containerElement)
|
||||
})
|
||||
}, [currentResultIndex, editor, results])
|
||||
|
||||
useEffect(() => {
|
||||
let containerElement: HTMLElement | null | undefined
|
||||
let rootElement: HTMLElement | null | undefined
|
||||
|
||||
editor.getEditorState().read(() => {
|
||||
rootElement = editor.getRootElement()
|
||||
containerElement = rootElement?.parentElement?.querySelector('.search-highlight-container')
|
||||
})
|
||||
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
containerElement.style.height = `${rootElement.scrollHeight}px`
|
||||
containerElement.style.overflow = 'visible'
|
||||
})
|
||||
resizeObserver.observe(rootElement)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!rootElement || !containerElement) {
|
||||
return
|
||||
}
|
||||
|
||||
containerElement.style.top = `-${rootElement.scrollTop}px`
|
||||
}
|
||||
|
||||
rootElement.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
rootElement?.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchDialog
|
||||
open={isSearchActive}
|
||||
closeDialog={() => {
|
||||
dispatch({ type: 'toggle-search' })
|
||||
dispatch({ type: 'reset-search' })
|
||||
editor.focus()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type SuperSearchResult = {
|
||||
node: Text
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
export type SuperSearchContextState = {
|
||||
query: string
|
||||
results: SuperSearchResult[]
|
||||
currentResultIndex: number
|
||||
isCaseSensitive: boolean
|
||||
isSearchActive: boolean
|
||||
isReplaceMode: boolean
|
||||
}
|
||||
|
||||
export type SuperSearchContextAction =
|
||||
| { type: 'set-query'; query: string }
|
||||
| { type: 'set-results'; results: SuperSearchResult[] }
|
||||
| { type: 'clear-results' }
|
||||
| { type: 'set-current-result-index'; index: number }
|
||||
| { type: 'go-to-next-result' }
|
||||
| { type: 'go-to-previous-result' }
|
||||
| { type: 'toggle-case-sensitive' }
|
||||
| { type: 'toggle-replace-mode' }
|
||||
| { type: 'toggle-search' }
|
||||
| { type: 'reset-search' }
|
||||
|
||||
export type SuperSearchReplaceEvent = {
|
||||
type: 'next' | 'all'
|
||||
replace: string
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SuperSearchResult } from './Types'
|
||||
|
||||
export const createSearchHighlightElement = (
|
||||
result: SuperSearchResult,
|
||||
rootElement: Element,
|
||||
containerElement: Element,
|
||||
) => {
|
||||
const rootElementRect = rootElement.getBoundingClientRect()
|
||||
|
||||
const range = document.createRange()
|
||||
range.setStart(result.node, result.startIndex)
|
||||
range.setEnd(result.node, result.endIndex)
|
||||
|
||||
const rects = range.getClientRects()
|
||||
|
||||
Array.from(rects).forEach((rect, index) => {
|
||||
const id = `search-${result.startIndex}-${result.endIndex}-${index}`
|
||||
|
||||
const existingHighlightElement = document.getElementById(id)
|
||||
|
||||
if (existingHighlightElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const highlightElement = document.createElement('div')
|
||||
highlightElement.style.position = 'absolute'
|
||||
highlightElement.style.zIndex = '1000'
|
||||
highlightElement.style.transform = `translate(${rect.left - rootElementRect.left}px, ${
|
||||
rect.top - rootElementRect.top + rootElement.scrollTop
|
||||
}px)`
|
||||
highlightElement.style.width = `${rect.width}px`
|
||||
highlightElement.style.height = `${rect.height}px`
|
||||
highlightElement.style.backgroundColor = 'var(--sn-stylekit-info-color)'
|
||||
highlightElement.style.opacity = '0.5'
|
||||
highlightElement.className = 'search-highlight'
|
||||
highlightElement.id = id
|
||||
|
||||
containerElement.appendChild(highlightElement)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const getAllTextNodesInElement = (element: HTMLElement) => {
|
||||
const textNodes: Text[] = []
|
||||
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
|
||||
let node = walk.nextNode()
|
||||
while (node) {
|
||||
textNodes.push(node as Text)
|
||||
node = walk.nextNode()
|
||||
}
|
||||
return textNodes
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMark
|
||||
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
|
||||
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
|
||||
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
|
||||
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
|
||||
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
|
||||
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
@@ -162,48 +164,48 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
||||
return (
|
||||
<div className="font-editor relative h-full w-full">
|
||||
<ErrorBoundary>
|
||||
<>
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
<BlocksEditorComposer
|
||||
readonly={note.current.locked}
|
||||
initialValue={note.current.text}
|
||||
nodes={[FileNode, BubbleNode]}
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
<BlocksEditorComposer
|
||||
readonly={note.current.locked}
|
||||
initialValue={note.current.text}
|
||||
nodes={[FileNode, BubbleNode]}
|
||||
>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
ignoreFirstChange={controller.isTemplateNote}
|
||||
className={classNames(
|
||||
'blocks-editor relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
|
||||
lineHeight && `leading-${lineHeight.toLowerCase()}`,
|
||||
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
|
||||
)}
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
ignoreFirstChange={controller.isTemplateNote}
|
||||
className={classNames(
|
||||
'relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
|
||||
lineHeight && `leading-${lineHeight.toLowerCase()}`,
|
||||
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
|
||||
)}
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note.current} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<GetMarkdownPlugin ref={getMarkdownPlugin} />
|
||||
<DatetimePlugin />
|
||||
<PasswordPlugin />
|
||||
<AutoLinkPlugin />
|
||||
<ChangeContentCallbackPlugin
|
||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
<ExportPlugin />
|
||||
<ReadonlyPlugin note={note.current} />
|
||||
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</FilesControllerProvider>
|
||||
</LinkingControllerProvider>
|
||||
|
||||
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
|
||||
</>
|
||||
<ItemSelectionPlugin currentNote={note.current} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<GetMarkdownPlugin ref={getMarkdownPlugin} />
|
||||
<DatetimePlugin />
|
||||
<PasswordPlugin />
|
||||
<AutoLinkPlugin />
|
||||
<ChangeContentCallbackPlugin
|
||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
<ExportPlugin />
|
||||
<ReadonlyPlugin note={note.current} />
|
||||
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
|
||||
<SuperSearchContextProvider>
|
||||
<SearchPlugin />
|
||||
</SuperSearchContextProvider>
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</FilesControllerProvider>
|
||||
</LinkingControllerProvider>
|
||||
{showMarkdownPreview && <SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
131
packages/web/src/javascripts/Constants/AnimationConfigs.ts
Normal file
131
packages/web/src/javascripts/Constants/AnimationConfigs.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
94
packages/web/src/javascripts/Hooks/useLifecycleAnimation.ts
Normal file
94
packages/web/src/javascripts/Hooks/useLifecycleAnimation.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user