diff --git a/packages/mobile/fastlane/Fastfile b/packages/mobile/fastlane/Fastfile index 35f213a7991..2565bda1c20 100755 --- a/packages/mobile/fastlane/Fastfile +++ b/packages/mobile/fastlane/Fastfile @@ -115,6 +115,10 @@ platform :ios do end platform :android do + def android_package_name(variant) + variant == 'dev' ? 'com.standardnotes.dev' : 'com.standardnotes' + end + def build_android(variant, versionCode) gradle( task: 'bundle', @@ -132,6 +136,7 @@ platform :android do sh('yarn run android:bundle') build_android(variant, versionCode) upload_to_play_store( + package_name: android_package_name(variant), track: track, json_key_data: (ENV['GOOGLE_PLAY_JSON_KEY_DATA']).to_s, skip_upload_metadata: true, diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts index c799fdd5aa4..0a89e24f354 100644 --- a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.spec.ts @@ -159,7 +159,6 @@ describe('GetFeatureStatusUseCase', () => { ).toEqual(FeatureStatus.InCurrentPlanButExpired) }) }) - describe('third party features', () => { it('should return Entitled for third-party features', () => { const mockComponent = { diff --git a/packages/ui-services/src/Keyboard/KeyboardCommands.ts b/packages/ui-services/src/Keyboard/KeyboardCommands.ts index b23a447c36a..d0ce1c7aa0f 100644 --- a/packages/ui-services/src/Keyboard/KeyboardCommands.ts +++ b/packages/ui-services/src/Keyboard/KeyboardCommands.ts @@ -32,6 +32,12 @@ export const SUPER_SEARCH_TOGGLE_CASE_SENSITIVE = createKeyboardCommand('SUPER_S 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 UNIVERSAL_TOGGLE_SEARCH = createKeyboardCommand('UNIVERSAL_TOGGLE_SEARCH') +export const UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE = createKeyboardCommand('UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE') +export const UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE = createKeyboardCommand('UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE') +export const UNIVERSAL_SEARCH_NEXT_RESULT = createKeyboardCommand('UNIVERSAL_SEARCH_NEXT_RESULT') +export const UNIVERSAL_SEARCH_PREVIOUS_RESULT = createKeyboardCommand('UNIVERSAL_SEARCH_PREVIOUS_RESULT') export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MARKDOWN_PREVIEW') export const SUPER_EXPORT_JSON = createKeyboardCommand('SUPER_EXPORT_JSON') diff --git a/packages/ui-services/src/Keyboard/KeyboardShortcut.ts b/packages/ui-services/src/Keyboard/KeyboardShortcut.ts index 085d59003ea..27d3f4931ff 100644 --- a/packages/ui-services/src/Keyboard/KeyboardShortcut.ts +++ b/packages/ui-services/src/Keyboard/KeyboardShortcut.ts @@ -19,7 +19,13 @@ export type PlatformedKeyboardShortcut = KeyboardShortcut & { platform: Platform } -export type KeyboardShortcutCategory = 'General' | 'Notes list' | 'Current note' | 'Super notes' | 'Formatting' +export type KeyboardShortcutCategory = + | 'General' + | 'Notes list' + | 'Current note' + | 'Super notes' + | 'Formatting' + | 'Search' export interface KeyboardShortcutHelpItem extends Omit { command?: KeyboardCommand diff --git a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts index 1f4f4eaca01..daaff3cf97b 100644 --- a/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts +++ b/packages/ui-services/src/Keyboard/getKeyboardShortcuts.ts @@ -28,6 +28,11 @@ import { SUPER_SEARCH_NEXT_RESULT, SUPER_SEARCH_PREVIOUS_RESULT, SUPER_SEARCH_TOGGLE_REPLACE_MODE, + UNIVERSAL_TOGGLE_SEARCH, + UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE, + UNIVERSAL_SEARCH_NEXT_RESULT, + UNIVERSAL_SEARCH_PREVIOUS_RESULT, + UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE, CHANGE_EDITOR_WIDTH_COMMAND, SUPER_TOGGLE_TOOLBAR, TOGGLE_KEYBOARD_SHORTCUTS_MODAL, @@ -178,6 +183,31 @@ export function getKeyboardShortcuts(platform: Platform, _environment: Environme key: 'F3', modifiers: [KeyboardModifier.Shift], }, + { + command: UNIVERSAL_TOGGLE_SEARCH, + key: 'f', + modifiers: [primaryModifier], + }, + { + command: UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE, + key: isMacPlatform(platform) ? undefined : 'h', + code: isMacPlatform(platform) ? 'KeyF' : undefined, + modifiers: isMacPlatform(platform) ? [KeyboardModifier.Alt, primaryModifier] : [primaryModifier], + }, + { + command: UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE, + key: 'c', + modifiers: [KeyboardModifier.Alt], + }, + { + command: UNIVERSAL_SEARCH_NEXT_RESULT, + key: 'F3', + }, + { + command: UNIVERSAL_SEARCH_PREVIOUS_RESULT, + key: 'F3', + modifiers: [KeyboardModifier.Shift], + }, { command: SUPER_SHOW_MARKDOWN_PREVIEW, key: 'm', diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index 37ebb8db3dc..c164b665864 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -47,7 +47,8 @@ import { } from './TransactionFunctions' import { SuperEditorContentId } from '../SuperEditor/Constants' import { NoteViewController } from './Controller/NoteViewController' -import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor' +import { PlainEditorInterface } from './PlainEditor/PlainEditor' +import { PlainEditorSearchContainer } from './PlainEditor/search/PlainEditorSearchContainer' import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator' import CollaborationInfoHUD from './CollaborationInfoHUD' import Button from '../Button/Button' @@ -977,7 +978,7 @@ class NoteView extends AbstractComponent { )} {editorMode === 'plain' && ( - void onBlur: (event: FocusEvent) => void + onTextChange?: () => void + isSearchMode?: boolean + search?: PlainEditorSearchHighlightState } export type PlainEditorInterface = { focus: () => void + getText: () => string + getTextarea: () => HTMLTextAreaElement | null + setSelection: ( + start: number, + end: number, + options?: { focus?: boolean; scrollIntoView?: boolean; selectInEditor?: boolean }, + ) => void + replaceRange: (start: number, end: number, replacement: string) => Promise + replaceAllRanges: (ranges: TextRange[], replacement: string) => Promise + onTextChange: (callback: () => void) => Disposer } export const PlainEditor = forwardRef( - ({ application, spellcheck, controller, locked, onFocus, onBlur }, ref) => { + ({ application, spellcheck, controller, locked, onFocus, onBlur, onTextChange, isSearchMode, search }, ref) => { const [editorText, setEditorText] = useState() const [textareaUnloading, setTextareaUnloading] = useState(false) const [lineHeight, setLineHeight] = useState() @@ -59,12 +87,120 @@ export const PlainEditor = forwardRef( const tabObserverDisposer = useRef() const mutationObserver = useRef(null) - - useImperativeHandle(ref, () => ({ - focus() { - focusEditor() + const textareaRef = useRef(null) + const textChangeObservers = useRef(new Set<() => void>()) + + const notifyTextChange = useCallback(() => { + onTextChange?.() + textChangeObservers.current.forEach((observer) => observer()) + }, [onTextChange]) + + const persistText = useCallback( + (text: string, options?: { bypassDebouncer?: boolean }) => { + setEditorText(text) + setIsPendingLocalPropagation(true) + + return controller + .saveAndAwaitLocalPropagation({ + text, + isUserModified: true, + bypassDebouncer: options?.bypassDebouncer, + }) + .then(() => { + setIsPendingLocalPropagation(false) + }) }, - })) + [controller], + ) + + const focusEditor = useCallback(() => { + const element = document.getElementById(ElementIds.NoteTextEditor) + if (element) { + lastEditorFocusEventSource.current = EditorEventSource.Script + element.focus() + } + }, []) + + useImperativeHandle( + ref, + () => ({ + focus() { + focusEditor() + }, + getText() { + return textareaRef.current?.value ?? editorText ?? '' + }, + getTextarea() { + return textareaRef.current + }, + setSelection(start, end, options) { + const textarea = textareaRef.current + if (!textarea) { + return + } + + const scrollTopBefore = textarea.scrollTop + const shouldSelectInEditor = options?.selectInEditor ?? !search?.isOpen + + if (!shouldSelectInEditor) { + if (options?.scrollIntoView !== false) { + scrollPlainTextareaToOffset(textarea, start, end) + } else { + textarea.scrollTop = scrollTopBefore + } + return + } + + textarea.setSelectionRange(start, end) + + if (options?.scrollIntoView !== false) { + scrollPlainTextareaToOffset(textarea, start, end) + } else { + textarea.scrollTop = scrollTopBefore + } + + if (options?.focus) { + lastEditorFocusEventSource.current = EditorEventSource.Script + textarea.focus() + } + }, + async replaceRange(start, end, replacement) { + const textarea = textareaRef.current + if (!textarea || locked) { + return + } + + const nextText = replaceTextRangeWithUndoSupport(textarea, start, end, replacement) + + void persistText(nextText, { bypassDebouncer: true }) + + if (!search?.isOpen) { + notifyTextChange() + } + }, + async replaceAllRanges(ranges, replacement) { + const textarea = textareaRef.current + if (!textarea || locked || ranges.length < 1) { + return + } + + const nextText = replaceAllTextRangesWithUndoSupport(textarea, ranges, replacement) + + void persistText(nextText, { bypassDebouncer: true }) + + if (!search?.isOpen) { + notifyTextChange() + } + }, + onTextChange(callback) { + textChangeObservers.current.add(callback) + return () => { + textChangeObservers.current.delete(callback) + } + }, + }), + [editorText, focusEditor, locked, notifyTextChange, persistText, search?.isOpen], + ) useEffect(() => { return () => { @@ -89,6 +225,7 @@ export const PlainEditor = forwardRef( updatedNote.noteType !== note.current.noteType ) { setEditorText(updatedNote.text) + notifyTextChange() } } @@ -103,17 +240,14 @@ export const PlainEditor = forwardRef( controller.item.editorIdentifier, controller.item.noteType, isPendingLocalPropagation, + notifyTextChange, ]) const onTextAreaChange: ChangeEventHandler = ({ currentTarget }) => { const text = currentTarget.value - setEditorText(text) - - setIsPendingLocalPropagation(true) - - void controller.saveAndAwaitLocalPropagation({ text: text, isUserModified: true }).then(() => { - setIsPendingLocalPropagation(false) + void persistText(text).then(() => { + notifyTextChange() }) } @@ -157,14 +291,6 @@ export const PlainEditor = forwardRef( return disposer }, [application, scrollMobileCursorIntoViewAfterWebviewResize]) - const focusEditor = useCallback(() => { - const element = document.getElementById(ElementIds.NoteTextEditor) - if (element) { - lastEditorFocusEventSource.current = EditorEventSource.Script - element.focus() - } - }, []) - useEffect(() => { const shouldFocus = controller.isTemplateNote && controller.templateNoteOptions?.autofocusBehavior === 'editor' @@ -212,8 +338,10 @@ export const PlainEditor = forwardRef( }, [spellcheck, previousSpellcheck]) const onRef = useCallback( - (ref: HTMLTextAreaElement | null) => { - if (tabObserverDisposer.current || !ref) { + (element: HTMLTextAreaElement | null) => { + textareaRef.current = element + + if (tabObserverDisposer.current || !element) { return } @@ -251,18 +379,9 @@ export const PlainEditor = forwardRef( editor.selectionStart = editor.selectionEnd = start + 4 } - setEditorText(editor.value) - - setIsPendingLocalPropagation(true) - - void controller - .saveAndAwaitLocalPropagation({ - text: editor.value, - isUserModified: true, - }) - .then(() => { - setIsPendingLocalPropagation(false) - }) + void persistText(editor.value).then(() => { + notifyTextChange() + }) }, }) @@ -282,14 +401,54 @@ export const PlainEditor = forwardRef( mutationObserver.current = observer }, - [application.keyboardService, controller], + [application.keyboardService, notifyTextChange, persistText], ) + const { + isOpen: searchIsOpen = false, + query: searchQuery = '', + isCaseSensitive: searchIsCaseSensitive = false, + shouldHighlightAll: searchShouldHighlightAll = false, + activeMatch: searchActiveMatch, + } = search ?? {} + + const searchHighlightHtml = useMemo(() => { + if (!searchIsOpen || !searchQuery) { + return null + } + + const text = editorText ?? '' + const matches = findStringMatches(text, searchQuery, searchIsCaseSensitive) + + return buildPlainSearchHighlightHtml( + { + isOpen: true, + query: searchQuery, + results: matches.map((range) => ({ id: `plain-${range.start}`, payload: range })), + currentResult: searchActiveMatch ? { id: 'active', payload: searchActiveMatch } : undefined, + shouldHighlightAll: searchShouldHighlightAll, + }, + () => text, + ) + }, [editorText, searchIsOpen, searchQuery, searchIsCaseSensitive, searchShouldHighlightAll, searchActiveMatch]) + if (textareaUnloading) { return null } - return ( + const editorTypographyClassName = classNames( + lineHeight && `leading-${lineHeight.toLowerCase()}`, + responsiveFontSize, + ) + + const textareaClassName = classNames( + 'editable font-editor flex-grow', + editorTypographyClassName, + searchHighlightHtml != null && 'plain-editor-with-search-highlights', + isIOS() && '!pb-12', + ) + + const textareaElement = ( ) + + if (!isSearchMode) { + return textareaElement + } + + return ( +
+ {searchHighlightHtml != null && ( + + )} + {textareaElement} +
+ ) }, ) diff --git a/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.spec.ts b/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.spec.ts new file mode 100644 index 00000000000..29574589f66 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.spec.ts @@ -0,0 +1,159 @@ +/** + * @jest-environment jsdom + */ + +import { + replaceAllTextRangesWithUndoSupport, + replaceTextRangeWithUndoSupport, +} from './replacePlainTextWithUndoSupport' + +describe('replacePlainTextWithUndoSupport', () => { + beforeEach(() => { + document.queryCommandSupported = jest.fn(() => true) + document.execCommand = jest.fn(() => true) + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('replaces text and keeps focus on the replace input', () => { + const textarea = document.createElement('textarea') + textarea.value = 'hello world' + const replaceInput = document.createElement('input') + document.body.appendChild(textarea) + document.body.appendChild(replaceInput) + replaceInput.focus() + + document.execCommand = jest.fn((_command, _showUi, text: string) => { + const start = textarea.selectionStart ?? 0 + const end = textarea.selectionEnd ?? 0 + textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end) + return true + }) + + replaceTextRangeWithUndoSupport(textarea, 6, 11, 'earth') + + expect(textarea.value).toBe('hello earth') + expect(textarea.selectionStart).toBe(0) + expect(textarea.selectionEnd).toBe(0) + expect(document.activeElement).toBe(replaceInput) + }) + + it('replaces all matches in one insertText operation', () => { + const textarea = document.createElement('textarea') + textarea.value = 'hello hello' + const replaceInput = document.createElement('input') + document.body.appendChild(textarea) + document.body.appendChild(replaceInput) + replaceInput.focus() + + document.execCommand = jest.fn((_command, _showUi, text: string) => { + textarea.value = text + return true + }) + + const result = replaceAllTextRangesWithUndoSupport( + textarea, + [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + ], + 'bye', + ) + + expect(result).toBe('bye bye') + expect(document.execCommand).toHaveBeenCalledWith('insertText', false, 'bye bye') + expect(document.activeElement).toBe(replaceInput) + }) + + it('clears editor selection and restores replace input focus after undo', () => { + const textarea = document.createElement('textarea') + textarea.value = 'hello hello' + const replaceInput = document.createElement('input') + document.body.appendChild(textarea) + document.body.appendChild(replaceInput) + replaceInput.focus() + + document.execCommand = jest.fn((_command, _showUi, text: string) => { + textarea.value = text + return true + }) + + replaceAllTextRangesWithUndoSupport( + textarea, + [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + ], + 'bye', + ) + + textarea.focus() + textarea.value = 'hello hello' + textarea.setSelectionRange(0, textarea.value.length) + textarea.dispatchEvent(new InputEvent('input', { inputType: 'historyUndo', bubbles: true })) + + expect(textarea.selectionStart).toBe(0) + expect(textarea.selectionEnd).toBe(0) + expect(document.activeElement).toBe(replaceInput) + }) + + it('clears editor selection and restores replace input focus after undoing a single replace', () => { + const textarea = document.createElement('textarea') + textarea.value = 'hello world' + const replaceInput = document.createElement('input') + document.body.appendChild(textarea) + document.body.appendChild(replaceInput) + replaceInput.focus() + + document.execCommand = jest.fn((_command, _showUi, text: string) => { + const start = textarea.selectionStart ?? 0 + const end = textarea.selectionEnd ?? 0 + textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end) + return true + }) + + replaceTextRangeWithUndoSupport(textarea, 6, 11, 'earth') + + textarea.focus() + textarea.value = 'hello world' + textarea.setSelectionRange(6, 11) + textarea.dispatchEvent(new InputEvent('input', { inputType: 'historyUndo', bubbles: true })) + + expect(textarea.selectionStart).toBe(0) + expect(textarea.selectionEnd).toBe(0) + expect(document.activeElement).toBe(replaceInput) + }) + + it('falls back to manual replacement when execCommand fails', () => { + const textarea = document.createElement('textarea') + textarea.value = 'hello world' + const replaceInput = document.createElement('input') + document.body.appendChild(textarea) + document.body.appendChild(replaceInput) + replaceInput.focus() + + document.execCommand = jest.fn(() => false) + + replaceTextRangeWithUndoSupport(textarea, 6, 11, 'earth') + + expect(textarea.value).toBe('hello earth') + expect(textarea.selectionStart).toBe(0) + expect(textarea.selectionEnd).toBe(0) + expect(document.activeElement).toBe(replaceInput) + + textarea.focus() + textarea.value = 'hello world' + textarea.setSelectionRange(6, 11) + textarea.dispatchEvent(new InputEvent('input', { inputType: 'historyUndo', bubbles: true })) + + expect(textarea.selectionStart).toBe(6) + expect(textarea.selectionEnd).toBe(11) + expect(document.activeElement).toBe(textarea) + }) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.ts b/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.ts new file mode 100644 index 00000000000..1d5ab227481 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/replacePlainTextWithUndoSupport.ts @@ -0,0 +1,100 @@ +import { applyTextReplacements } from '../UniversalSearch/applyTextReplacements' +import { TextRange } from '../UniversalSearch/types' + +type PendingUndo = { + previousFocus: HTMLElement + isUndoSelection: (textarea: HTMLTextAreaElement) => boolean +} + +const pendingUndo = new WeakMap() + +function moveFocusOutOfEditor(textarea: HTMLTextAreaElement, previousFocus: HTMLElement) { + textarea.setSelectionRange(0, 0) + previousFocus.focus() +} + +function handleUndoInput(event: Event) { + const textarea = event.currentTarget as HTMLTextAreaElement + const pending = pendingUndo.get(textarea) + + if (!pending || !(event instanceof InputEvent) || event.inputType !== 'historyUndo') { + return + } + + if (!pending.isUndoSelection(textarea) || !pending.previousFocus.isConnected) { + return + } + + pendingUndo.delete(textarea) + + // The browser restores the undone selection after this event, so it can only be cleared next frame. + requestAnimationFrame(() => moveFocusOutOfEditor(textarea, pending.previousFocus)) +} + +function replaceSelection( + textarea: HTMLTextAreaElement, + start: number, + end: number, + text: string, + isUndoSelection: PendingUndo['isUndoSelection'], +): string { + const previousFocus = document.activeElement + + // Re-registering the same listener reference is a no-op, so no bookkeeping is needed. + textarea.addEventListener('input', handleUndoInput) + + textarea.focus() + textarea.setSelectionRange(start, end) + + const hasNativeUndoEntry = document.execCommand('insertText', false, text) + if (!hasNativeUndoEntry) { + textarea.value = textarea.value.slice(0, start) + text + textarea.value.slice(end) + } + + if (!(previousFocus instanceof HTMLElement) || previousFocus === textarea || !previousFocus.isConnected) { + return textarea.value + } + + if (hasNativeUndoEntry) { + pendingUndo.set(textarea, { previousFocus, isUndoSelection }) + } + + moveFocusOutOfEditor(textarea, previousFocus) + + return textarea.value +} + +export function replaceTextRangeWithUndoSupport( + textarea: HTMLTextAreaElement, + start: number, + end: number, + replacement: string, +): string { + return replaceSelection( + textarea, + start, + end, + replacement, + (target) => target.selectionStart === start && target.selectionEnd === end, + ) +} + +export function replaceAllTextRangesWithUndoSupport( + textarea: HTMLTextAreaElement, + ranges: TextRange[], + replacement: string, +): string { + if (ranges.length < 1) { + return textarea.value + } + + const nextText = applyTextReplacements(textarea.value, ranges, replacement) + + return replaceSelection( + textarea, + 0, + textarea.value.length, + nextText, + (target) => target.selectionStart === 0 && target.selectionEnd === target.value.length && target.value.length > 0, + ) +} diff --git a/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchBackdrop.tsx b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchBackdrop.tsx new file mode 100644 index 00000000000..305b62c1d53 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchBackdrop.tsx @@ -0,0 +1,50 @@ +import { classNames } from '@standardnotes/utils' +import { isIOS } from '@standardnotes/ui-services' +import { RefObject, useEffect, useRef } from 'react' + +type Props = { + html: string + textareaRef: RefObject + typographyClassName: string + syncDependency?: string +} + +export function PlainEditorSearchBackdrop({ html, textareaRef, typographyClassName, syncDependency }: Props) { + const backdropScrollRef = useRef(null) + + useEffect(() => { + const textarea = textareaRef.current + const backdrop = backdropScrollRef.current + if (!textarea || !backdrop) { + return + } + + const syncScroll = () => { + backdrop.scrollTop = textarea.scrollTop + backdrop.scrollLeft = textarea.scrollLeft + } + + syncScroll() + textarea.addEventListener('scroll', syncScroll) + + return () => { + textarea.removeEventListener('scroll', syncScroll) + } + }, [textareaRef, syncDependency]) + + const backdropClassName = classNames( + 'plain-editor-search-backdrop font-editor whitespace-pre-wrap break-words', + typographyClassName, + isIOS() && '!pb-12', + ) + + return ( +
+
+
+ ) +} diff --git a/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchContainer.tsx b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchContainer.tsx new file mode 100644 index 00000000000..3cac3696501 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchContainer.tsx @@ -0,0 +1,135 @@ +import { ComponentProps, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { observer } from 'mobx-react-lite' +import { useApplication } from '@/Components/ApplicationProvider' +import { useKeyboardService } from '@/Components/KeyboardServiceProvider' +import { + keyboardStringForShortcut, + UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE, + UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE, + UNIVERSAL_TOGGLE_SEARCH, +} from '@standardnotes/ui-services' +import { UniversalSearchController } from '../../UniversalSearch/UniversalSearchController' +import { registerUniversalSearchKeyboardHandlers } from '../../UniversalSearch/registerUniversalSearchKeyboardHandlers' +import { createPlainEditorUniversalSearchProvider } from '../../UniversalSearch/providers/PlainEditorUniversalSearchProvider' +import { UniversalSearchShell } from '../../UniversalSearch/UniversalSearchShell' +import { PlainEditor, PlainEditorInterface } from '../PlainEditor' + +type PlainEditorProps = ComponentProps + +export const PlainEditorSearchContainer = observer( + forwardRef(function PlainEditorSearchContainer(props, ref) { + const application = useApplication() + const keyboardService = useKeyboardService() + const plainEditorRef = useRef(null) + const isUniversalSearchEnabled = application.featuresController.isUniversalSearchEnabled() + + const noteUuid = props.controller.item.uuid + + const controller = useMemo(() => { + if (!isUniversalSearchEnabled) { + return undefined + } + + const provider = createPlainEditorUniversalSearchProvider({ + getEditor: () => plainEditorRef.current ?? undefined, + }) + + return new UniversalSearchController(provider) + }, [isUniversalSearchEnabled]) + + useEffect(() => { + controller?.resetContext() + }, [noteUuid, controller]) + + useImperativeHandle( + ref, + () => ({ + focus: () => plainEditorRef.current?.focus(), + getText: () => plainEditorRef.current?.getText() ?? '', + getTextarea: () => plainEditorRef.current?.getTextarea() ?? null, + setSelection: (start, end, options) => plainEditorRef.current?.setSelection(start, end, options), + replaceRange: async (start, end, replacement) => { + await plainEditorRef.current?.replaceRange(start, end, replacement) + }, + replaceAllRanges: async (ranges, replacement) => { + await plainEditorRef.current?.replaceAllRanges(ranges, replacement) + }, + onTextChange: (callback) => plainEditorRef.current?.onTextChange(callback) ?? (() => undefined), + }), + [], + ) + + useEffect(() => { + return () => { + controller?.deinit() + } + }, [controller]) + + const wasSearchOpenRef = useRef(false) + + useEffect(() => { + if (wasSearchOpenRef.current && !controller?.isOpen) { + plainEditorRef.current?.focus() + } + + wasSearchOpenRef.current = controller?.isOpen ?? false + }, [controller?.isOpen]) + + const handleTextChange = useCallback(() => { + if (!controller?.isOpen) { + return + } + + controller.refreshSearch() + }, [controller]) + + useEffect(() => { + if (!controller || !isUniversalSearchEnabled) { + return + } + + return registerUniversalSearchKeyboardHandlers(keyboardService, controller) + }, [controller, isUniversalSearchEnabled, keyboardService]) + + const setPlainEditorRef = useCallback((editor: PlainEditorInterface | null) => { + plainEditorRef.current = editor + }, []) + + const search = controller + ? { + isOpen: controller.isOpen, + query: controller.query, + isCaseSensitive: controller.isCaseSensitive, + shouldHighlightAll: controller.shouldHighlightAll, + activeMatch: controller.currentResult?.payload, + } + : undefined + + const searchToggleShortcut = keyboardStringForShortcut( + keyboardService.keyboardShortcutForCommand(UNIVERSAL_TOGGLE_SEARCH), + ) + const toggleReplaceShortcut = keyboardStringForShortcut( + keyboardService.keyboardShortcutForCommand(UNIVERSAL_SEARCH_TOGGLE_REPLACE_MODE), + ) + const caseSensitivityShortcut = keyboardStringForShortcut( + keyboardService.keyboardShortcutForCommand(UNIVERSAL_SEARCH_TOGGLE_CASE_SENSITIVE), + ) + + if (!isUniversalSearchEnabled || !controller) { + return + } + + return ( +
+ + +
+ ) + }), +) diff --git a/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/buildPlainSearchHighlightHtml.spec.ts b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/buildPlainSearchHighlightHtml.spec.ts new file mode 100644 index 00000000000..b347294d867 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/buildPlainSearchHighlightHtml.spec.ts @@ -0,0 +1,68 @@ +import { buildPlainSearchHighlightHtml } from './buildPlainSearchHighlightHtml' + +describe('buildPlainSearchHighlightHtml', () => { + it('returns null when search is closed', () => { + expect( + buildPlainSearchHighlightHtml( + { + isOpen: false, + query: 'hello', + results: [], + shouldHighlightAll: true, + }, + () => 'hello world', + ), + ).toBeNull() + }) + + it('escapes html in note text', () => { + const html = buildPlainSearchHighlightHtml( + { + isOpen: true, + query: 'x', + results: [], + shouldHighlightAll: true, + }, + () => '