From bb183e525f29046694fe6462cf0b56f0a1be5622 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 8 Apr 2026 14:24:02 -0300 Subject: [PATCH 01/10] chore: add feature flag for universal search --- .../Domain/Feature/NativeFeatureIdentifier.ts | 1 + .../InternalFeatures/InternalFeature.ts | 1 + .../Features/UseCase/GetFeatureStatus.spec.ts | 20 +++++ .../Features/UseCase/GetFeatureStatus.ts | 1 + .../src/javascripts/Application/DevMode.ts | 1 + .../Controllers/FeaturesController.spec.ts | 75 +++++++++++++++++++ .../Controllers/FeaturesController.ts | 15 +++- packages/web/src/javascripts/FeatureTrunk.ts | 4 + 8 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/javascripts/Controllers/FeaturesController.spec.ts diff --git a/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts b/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts index 3877399a5b8..da5e87538f6 100644 --- a/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts +++ b/packages/features/src/Domain/Feature/NativeFeatureIdentifier.ts @@ -15,6 +15,7 @@ export class NativeFeatureIdentifier extends ValueObject { }) }) + describe('UniversalSearch', () => { + const universalSearchFeatureId = () => + NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.UniversalSearch).getValue() + + beforeEach(() => { + items.getDisplayableComponents.mockReturnValue([]) + }) + + it('returns NoUserSubscription when not in feature catalog and not a free feature', () => { + expect( + usecase.execute({ + featureId: universalSearchFeatureId(), + hasPaidAnyPartyOnlineOrOfflineSubscription: false, + firstPartyOnlineSubscription: undefined, + firstPartyRoles: undefined, + }), + ).toEqual(FeatureStatus.NoUserSubscription) + }) + }) + describe('third party features', () => { it('should return Entitled for third-party features', () => { const mockComponent = { diff --git a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts index 99aa283f3c5..9be84fdffbc 100644 --- a/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts +++ b/packages/snjs/lib/Services/Features/UseCase/GetFeatureStatus.ts @@ -118,6 +118,7 @@ export class GetFeatureStatusUseCase { NativeFeatureIdentifier.TYPES.DarkTheme, NativeFeatureIdentifier.TYPES.PlainEditor, NativeFeatureIdentifier.TYPES.ProtonTheme, + // NativeFeatureIdentifier.TYPES.UniversalSearch ].includes(featureId.value) } } diff --git a/packages/web/src/javascripts/Application/DevMode.ts b/packages/web/src/javascripts/Application/DevMode.ts index ead3f49c5f6..3b25a699039 100644 --- a/packages/web/src/javascripts/Application/DevMode.ts +++ b/packages/web/src/javascripts/Application/DevMode.ts @@ -4,6 +4,7 @@ import { WebApplicationInterface } from '@standardnotes/ui-services' export class DevMode { constructor(private application: WebApplicationInterface) { InternalFeatureService.get().enableFeature(InternalFeature.Vaults) + InternalFeatureService.get().enableFeature(InternalFeature.UniversalSearch) } /** Valid only when running a mock event publisher on port 3124 */ diff --git a/packages/web/src/javascripts/Controllers/FeaturesController.spec.ts b/packages/web/src/javascripts/Controllers/FeaturesController.spec.ts new file mode 100644 index 00000000000..c146c13fb1d --- /dev/null +++ b/packages/web/src/javascripts/Controllers/FeaturesController.spec.ts @@ -0,0 +1,75 @@ +import * as FeatureTrunk from '@/FeatureTrunk' +import { FeaturesClientInterface } from '@standardnotes/services' +import { NativeFeatureIdentifier, FeatureStatus, InternalEventBusInterface } from '@standardnotes/snjs' +import { FeaturesController } from './FeaturesController' + +describe('FeaturesController', () => { + describe('isUniversalSearchEnabled', () => { + let features: jest.Mocked> + let eventBus: jest.Mocked> + let controller: FeaturesController + let featureTrunkSpy: jest.SpyInstance + + beforeEach(() => { + featureTrunkSpy = jest.spyOn(FeatureTrunk, 'featureTrunkUniversalSearchEnabled').mockReturnValue(false) + + features = { + getFeatureStatus: jest.fn().mockImplementation((id: { value: string }) => { + if (id.value === NativeFeatureIdentifier.TYPES.UniversalSearch) { + return FeatureStatus.NoUserSubscription + } + return FeatureStatus.Entitled + }), + hasRole: jest.fn(), + } + eventBus = { + addEventHandler: jest.fn(), + } + controller = new FeaturesController( + features as unknown as FeaturesClientInterface, + eventBus as unknown as InternalEventBusInterface, + ) + }) + + afterEach(() => { + controller.deinit() + jest.restoreAllMocks() + }) + + it('returns true when internal feature trunk is enabled', () => { + featureTrunkSpy.mockReturnValue(true) + + expect(controller.isUniversalSearchEnabled()).toBe(true) + }) + + it('returns true when user has internal team role', () => { + features.hasRole.mockReturnValue(true) + + expect(controller.isUniversalSearchEnabled()).toBe(true) + }) + + it('returns true when getFeatureStatus is Entitled (e.g. after Universal Search is added to free features)', () => { + features.hasRole.mockReturnValue(false) + features.getFeatureStatus.mockImplementation((id: { value: string }) => { + if (id.value === NativeFeatureIdentifier.TYPES.UniversalSearch) { + return FeatureStatus.Entitled + } + return FeatureStatus.Entitled + }) + + expect(controller.isUniversalSearchEnabled()).toBe(true) + }) + + it('returns false when trunk, internal role, and entitlement do not apply', () => { + features.hasRole.mockReturnValue(false) + features.getFeatureStatus.mockImplementation((id: { value: string }) => { + if (id.value === NativeFeatureIdentifier.TYPES.UniversalSearch) { + return FeatureStatus.NoUserSubscription + } + return FeatureStatus.Entitled + }) + + expect(controller.isUniversalSearchEnabled()).toBe(false) + }) + }) +}) diff --git a/packages/web/src/javascripts/Controllers/FeaturesController.ts b/packages/web/src/javascripts/Controllers/FeaturesController.ts index 1ce26bac6f5..4fd96f048fc 100644 --- a/packages/web/src/javascripts/Controllers/FeaturesController.ts +++ b/packages/web/src/javascripts/Controllers/FeaturesController.ts @@ -13,7 +13,7 @@ import { import { action, makeObservable, observable, runInAction, when } from 'mobx' import { AbstractViewController } from './Abstract/AbstractViewController' import { CrossControllerEvent } from './CrossControllerEvent' -import { featureTrunkVaultsEnabled } from '@/FeatureTrunk' +import { featureTrunkUniversalSearchEnabled, featureTrunkVaultsEnabled } from '@/FeatureTrunk' export class FeaturesController extends AbstractViewController implements InternalEventHandlerInterface { hasFolders: boolean @@ -152,4 +152,17 @@ export class FeaturesController extends AbstractViewController implements Intern return featureTrunkVaultsEnabled() || isEntitledToFeature } + + isUniversalSearchEnabled(): boolean { + const status = this.features.getFeatureStatus( + NativeFeatureIdentifier.create(NativeFeatureIdentifier.TYPES.UniversalSearch).getValue(), + ) + const isEntitledToFeature = status === FeatureStatus.Entitled + + return ( + featureTrunkUniversalSearchEnabled() || + this.features.hasRole(RoleName.create(RoleName.NAMES.InternalTeamUser).getValue()) || + isEntitledToFeature + ) + } } diff --git a/packages/web/src/javascripts/FeatureTrunk.ts b/packages/web/src/javascripts/FeatureTrunk.ts index b8e6128c128..b74548d50b0 100644 --- a/packages/web/src/javascripts/FeatureTrunk.ts +++ b/packages/web/src/javascripts/FeatureTrunk.ts @@ -11,3 +11,7 @@ export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean { export function featureTrunkVaultsEnabled(): boolean { return InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults) } + +export function featureTrunkUniversalSearchEnabled(): boolean { + return InternalFeatureService.get().isFeatureEnabled(InternalFeature.UniversalSearch) +} From 29fd9e026e62bcb0bee7974d9ded0b4e303379ea Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Fri, 8 May 2026 15:04:15 -0300 Subject: [PATCH 02/10] feat: add generic component and controller --- .../UniversalSearchController.spec.ts | 124 ++++++++ .../UniversalSearchController.ts | 289 ++++++++++++++++++ .../UniversalSearch/UniversalSearchShell.tsx | 252 +++++++++++++++ .../NoteView/UniversalSearch/index.ts | 4 + .../providers/NoOpUniversalSearchProvider.ts | 17 ++ .../providers/UniversalSearchProvider.spec.ts | 60 ++++ .../NoteView/UniversalSearch/types.ts | 54 ++++ 7 files changed, 800 insertions(+) create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts new file mode 100644 index 00000000000..9b57a9621bf --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.spec.ts @@ -0,0 +1,124 @@ +import { createMockUniversalSearchProvider } from './providers/createMockUniversalSearchProvider' +import { + getNextUniversalSearchResultIndex, + getPreviousUniversalSearchResultIndex, + UniversalSearchController, +} from './UniversalSearchController' +import { UniversalSearchProvider } from './types' +import { NoOpUniversalSearchProvider } from './providers/NoOpUniversalSearchProvider' + +describe('UniversalSearchController', () => { + async function flushSearch() { + await Promise.resolve() + } + + it('calculates predictable wraparound indexes', () => { + expect(getNextUniversalSearchResultIndex(-1, 0)).toBe(-1) + expect(getNextUniversalSearchResultIndex(-1, 3)).toBe(0) + expect(getNextUniversalSearchResultIndex(2, 3)).toBe(0) + expect(getPreviousUniversalSearchResultIndex(-1, 0)).toBe(-1) + expect(getPreviousUniversalSearchResultIndex(0, 3)).toBe(2) + }) + + it('opens, searches, selects, navigates, and closes with reset state', async () => { + const onSelectResult = jest.fn() + const provider = createMockUniversalSearchProvider({ + documents: [{ id: 'one', text: 'hello hello' }], + onSelectResult, + }) + const controller = new UniversalSearchController(provider) + + controller.open() + controller.setQuery('hello') + await flushSearch() + + expect(controller.isOpen).toBe(true) + expect(controller.status).toBe('ready') + expect(controller.results).toHaveLength(2) + expect(controller.currentResultIndex).toBe(0) + + controller.goToNextResult() + + expect(controller.currentResultIndex).toBe(1) + expect(onSelectResult).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'one-6' })) + + controller.goToNextResult() + + expect(controller.currentResultIndex).toBe(0) + + controller.close() + + expect(controller.isOpen).toBe(false) + expect(controller.query).toBe('') + expect(controller.results).toEqual([]) + expect(controller.currentResultIndex).toBe(-1) + }) + + it('handles provider errors', async () => { + const provider: UniversalSearchProvider = { + id: 'failing', + capabilities: { + supportsSearch: true, + supportsReplace: false, + supportsHighlightAll: false, + }, + search: () => { + throw new Error('Provider failed') + }, + selectResult: jest.fn(), + clear: jest.fn(), + } + const controller = new UniversalSearchController(provider) + + controller.open() + controller.setQuery('hello') + await flushSearch() + + expect(controller.status).toBe('error') + expect(controller.error).toBe('Provider failed') + expect(controller.results).toEqual([]) + }) + + it('delegates replacement through provider contracts', async () => { + const provider = createMockUniversalSearchProvider({ + documents: [{ id: 'one', text: 'hello hello' }], + }) + const controller = new UniversalSearchController(provider) + + controller.open() + controller.setQuery('hello') + controller.setReplaceQuery('goodbye') + await flushSearch() + + await controller.replaceCurrentResult() + + expect(provider.getDocuments()[0].text).toBe('goodbye hello') + expect(controller.results).toHaveLength(1) + + await controller.replaceAllResults() + + expect(provider.getDocuments()[0].text).toBe('goodbye goodbye') + expect(controller.results).toHaveLength(0) + }) + + it('keeps unsupported providers stable and predictable', async () => { + const controller = new UniversalSearchController(NoOpUniversalSearchProvider) + + controller.open() + controller.setQuery('hello') + controller.goToNextResult() + await flushSearch() + + expect(controller.results).toEqual([]) + expect(controller.currentResultIndex).toBe(-1) + await expect(controller.replaceCurrentResult()).resolves.toBeUndefined() + }) + + it('does not open when access is disabled', () => { + const controller = new UniversalSearchController(NoOpUniversalSearchProvider, { isEnabled: false }) + + controller.open() + + expect(controller.isOpen).toBe(false) + }) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts new file mode 100644 index 00000000000..00468de695a --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchController.ts @@ -0,0 +1,289 @@ +import { action, computed, makeObservable, observable, runInAction } from 'mobx' +import { + UniversalSearchProvider, + UniversalSearchResult, + UniversalSearchResultPayload, + UniversalSearchStatus, +} from './types' + +export function getNextUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number { + if (resultCount < 1) { + return -1 + } + + const next = currentResultIndex + 1 + return next >= resultCount ? 0 : next +} + +export function getPreviousUniversalSearchResultIndex(currentResultIndex: number, resultCount: number): number { + if (resultCount < 1) { + return -1 + } + + const previous = currentResultIndex - 1 + return previous < 0 ? resultCount - 1 : previous +} + +function statusForEmptyQuery(provider: UniversalSearchProvider): UniversalSearchStatus { + return provider.capabilities.supportsSearch ? 'idle' : 'ready' +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return 'Search failed' +} + +interface UniversalSearchControllerOptions { + provider: UniversalSearchProvider + isEnabled?: boolean +} + +export class UniversalSearchController { + isOpen = false + query = '' + replaceQuery = '' + results: UniversalSearchResult[] = [] + currentResultIndex = -1 + status: UniversalSearchStatus = 'idle' + error: string | undefined = undefined + isCaseSensitive = false + isReplaceMode = false + shouldHighlightAll: boolean + + private searchId = 0 + private isEnabled: boolean + + constructor( + public readonly provider: UniversalSearchProvider, + options: Omit, 'provider'> = {}, + ) { + this.isEnabled = options.isEnabled ?? true + this.shouldHighlightAll = provider.capabilities.supportsHighlightAll + + makeObservable(this, { + isOpen: observable, + query: observable, + replaceQuery: observable, + results: observable, + currentResultIndex: observable, + status: observable, + error: observable, + isCaseSensitive: observable, + isReplaceMode: observable, + shouldHighlightAll: observable, + + currentResult: computed, + + open: action, + close: action, + setQuery: action, + setReplaceQuery: action, + toggleCaseSensitivity: action, + toggleReplaceMode: action, + setShouldHighlightAll: action, + goToNextResult: action, + goToPreviousResult: action, + setSearchResults: action, + setSearchError: action, + clearResults: action, + }) + } + + get currentResult(): UniversalSearchResult | undefined { + return this.results[this.currentResultIndex] + } + + deinit(): void { + this.searchId++ + void this.provider.clear() + this.results = [] + } + + open = (): void => { + if (!this.isEnabled) { + return + } + + this.isOpen = true + void this.search() + } + + close = (): void => { + void this.selectCurrentResult() + this.searchId++ + void this.provider.clear() + this.isOpen = false + this.query = '' + this.replaceQuery = '' + this.results = [] + this.currentResultIndex = -1 + this.status = 'idle' + this.error = undefined + this.isCaseSensitive = false + this.isReplaceMode = false + this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll + } + + setQuery = (query: string): void => { + this.query = query + void this.search() + } + + setReplaceQuery = (replaceQuery: string): void => { + this.replaceQuery = replaceQuery + } + + toggleCaseSensitivity = (): void => { + this.isCaseSensitive = !this.isCaseSensitive + void this.search() + } + + toggleReplaceMode = (): void => { + this.isReplaceMode = !this.isReplaceMode + } + + setShouldHighlightAll = (shouldHighlightAll: boolean): void => { + this.shouldHighlightAll = this.provider.capabilities.supportsHighlightAll && shouldHighlightAll + } + + goToNextResult = (): void => { + this.currentResultIndex = getNextUniversalSearchResultIndex(this.currentResultIndex, this.results.length) + void this.selectCurrentResult() + } + + goToPreviousResult = (): void => { + this.currentResultIndex = getPreviousUniversalSearchResultIndex(this.currentResultIndex, this.results.length) + void this.selectCurrentResult() + } + + selectCurrentResult = async (): Promise => { + const result = this.currentResult + if (!result) { + return + } + + await this.provider.selectResult(result) + } + + replaceCurrentResult = async (): Promise => { + if (!this.provider.capabilities.supportsReplace || !this.provider.replaceCurrentResult) { + return + } + + const result = this.currentResult + if (!result || !this.replaceQuery) { + return + } + + const nextResults = await this.provider.replaceCurrentResult(result, { + query: this.query, + isCaseSensitive: this.isCaseSensitive, + replaceQuery: this.replaceQuery, + }) + + await this.handleReplaceResult(nextResults) + } + + replaceAllResults = async (): Promise => { + if (!this.provider.capabilities.supportsReplace || !this.provider.replaceAllResults) { + return + } + + if (this.results.length < 1 || !this.replaceQuery) { + return + } + + const nextResults = await this.provider.replaceAllResults(this.results, { + query: this.query, + isCaseSensitive: this.isCaseSensitive, + replaceQuery: this.replaceQuery, + }) + + await this.handleReplaceResult(nextResults) + } + + private search = async (): Promise => { + if (!this.isOpen || !this.isEnabled) { + return + } + + const searchId = this.searchId + 1 + this.searchId = searchId + + if (!this.query || !this.provider.capabilities.supportsSearch) { + void this.provider.clear() + this.clearResults(statusForEmptyQuery(this.provider)) + return + } + + this.status = 'loading' + this.error = undefined + + try { + const results = await this.provider.search({ + query: this.query, + isCaseSensitive: this.isCaseSensitive, + }) + + if (searchId !== this.searchId) { + return + } + + runInAction(() => { + this.setSearchResults(results) + }) + + await this.selectCurrentResult() + } catch (error) { + if (searchId !== this.searchId) { + return + } + + runInAction(() => { + this.setSearchError(errorMessage(error)) + }) + } + } + + private handleReplaceResult = async (nextResults: UniversalSearchResult[] | void): Promise => { + if (Array.isArray(nextResults)) { + runInAction(() => { + this.setSearchResults(nextResults) + }) + return + } + + const results = await this.provider.search({ + query: this.query, + isCaseSensitive: this.isCaseSensitive, + }) + + runInAction(() => { + this.setSearchResults(results) + }) + } + + private setSearchResults(results: UniversalSearchResult[]): void { + this.results = results + this.currentResultIndex = results.length > 0 ? 0 : -1 + this.status = 'ready' + this.error = undefined + } + + private setSearchError(error: string): void { + this.results = [] + this.currentResultIndex = -1 + this.status = 'error' + this.error = error + } + + private clearResults(status: UniversalSearchStatus): void { + this.results = [] + this.currentResultIndex = -1 + this.status = status + this.error = undefined + } +} diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx new file mode 100644 index 00000000000..4c35e4cb437 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/UniversalSearchShell.tsx @@ -0,0 +1,252 @@ +import { ArrowDownIcon, ArrowRightIcon, ArrowUpIcon, CloseIcon } from '@standardnotes/icons' +import { classNames } from '@standardnotes/utils' +import { observer } from 'mobx-react-lite' +import { KeyboardEvent, useCallback } from 'react' +import Button from '../../Button/Button' +import Icon from '../../Icon/Icon' +import DecoratedInput from '../../Input/DecoratedInput' +import StyledTooltip from '../../StyledTooltip/StyledTooltip' +import { UniversalSearchController } from './UniversalSearchController' +import { UniversalSearchResultPayload } from './types' + +interface UniversalSearchShellProps { + controller: UniversalSearchController + className?: string + position?: 'absolute' | 'inline' + topMarginClassName?: string + closeShortcut?: string + replaceShortcut?: string + caseSensitivityShortcut?: string +} + +function statusLabel(controller: UniversalSearchController): string { + if (controller.status === 'loading') { + return 'Loading' + } + + if (controller.status === 'error') { + return controller.error || 'Error' + } + + if (!controller.query) { + return '' + } + + if (controller.results.length < 1) { + return '0' + } + + return `${controller.currentResultIndex + 1} / ${controller.results.length}` +} + +export const UniversalSearchShell = observer(function UniversalSearchShell({ + controller, + className, + position = 'absolute', + topMarginClassName, + closeShortcut, + replaceShortcut, + caseSensitivityShortcut, +}: UniversalSearchShellProps) { + const focusOnMount = useCallback((node: HTMLInputElement | null) => { + if (node) { + node.focus() + } + }, []) + + const canReplace = controller.provider.capabilities.supportsReplace + const canHighlightAll = controller.provider.capabilities.supportsHighlightAll + const hasResults = controller.results.length > 0 + const hasReplaceQuery = controller.replaceQuery.length > 0 + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || !hasResults) { + return + } + + if (event.shiftKey) { + controller.goToPreviousResult() + event.preventDefault() + return + } + + controller.goToNextResult() + event.preventDefault() + } + + const handleReplaceKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || !hasResults || !hasReplaceQuery || !canReplace) { + return + } + + if (event.ctrlKey && event.altKey) { + void controller.replaceAllResults() + event.preventDefault() + return + } + + void controller.replaceCurrentResult() + event.preventDefault() + } + + if (!controller.isOpen) { + return null + } + + const defaultTopMarginClassName = position === 'absolute' ? 'top-2 md:top-3' : 'mt-2 md:mt-3' + + return ( +
+ +
{ + if (event.key === 'Escape') { + controller.close() + } + }} + > +
+ + {statusLabel(controller)} +
, + ]} + /> + + + + +
+ {controller.isReplaceMode && ( +
+ { + controller.setReplaceQuery(event.target.value) + }} + onKeyDown={handleReplaceKeyDown} + className="rounded border border-border bg-default p-1 px-2" + ref={focusOnMount} + disabled={!canReplace} + aria-label="Replace" + /> + + +
+ )} +
+ + {!canHighlightAll && ( + + + + )} +
+
+ + ) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts new file mode 100644 index 00000000000..1a06cd117e1 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/index.ts @@ -0,0 +1,4 @@ +export * from './UniversalSearchShell' +export * from './UniversalSearchController' +export * from './types' +export * from './providers/NoOpUniversalSearchProvider' diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts new file mode 100644 index 00000000000..83454411376 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/NoOpUniversalSearchProvider.ts @@ -0,0 +1,17 @@ +import { UniversalSearchProvider } from '../types' + +export const NoOpUniversalSearchProvider: UniversalSearchProvider = { + id: 'noop', + capabilities: { + supportsSearch: false, + supportsReplace: false, + supportsHighlightAll: false, + }, + search: () => [], + selectResult: () => { + return + }, + clear: () => { + return + }, +} diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts new file mode 100644 index 00000000000..1f9dcb64660 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/UniversalSearchProvider.spec.ts @@ -0,0 +1,60 @@ +/** + * @jest-environment jsdom + */ + +import { NoOpUniversalSearchProvider } from './NoOpUniversalSearchProvider' +import { createMockUniversalSearchProvider } from './createMockUniversalSearchProvider' + +describe('UniversalSearchProvider', () => { + it('returns no results for unsupported editors', async () => { + expect(await NoOpUniversalSearchProvider.search({ query: 'hello', isCaseSensitive: false })).toEqual([]) + expect(NoOpUniversalSearchProvider.capabilities.supportsSearch).toBe(false) + expect(NoOpUniversalSearchProvider.capabilities.supportsReplace).toBe(false) + }) + + it('searches documents with a mock provider', async () => { + const provider = createMockUniversalSearchProvider({ + documents: [ + { id: 'one', text: 'Hello world' }, + { id: 'two', text: 'hello again' }, + ], + }) + + const results = await provider.search({ query: 'hello', isCaseSensitive: false }) + + expect(results.map(({ id }) => id)).toEqual(['one-0', 'two-0']) + }) + + it('honors case sensitivity in the mock provider', async () => { + const provider = createMockUniversalSearchProvider({ + documents: [{ id: 'one', text: 'Hello hello' }], + }) + + expect(await provider.search({ query: 'hello', isCaseSensitive: true })).toHaveLength(1) + expect(await provider.search({ query: 'hello', isCaseSensitive: false })).toHaveLength(2) + }) + + it('supports current and all replacements in the mock provider', async () => { + const provider = createMockUniversalSearchProvider({ + documents: [{ id: 'one', text: 'hello hello' }], + }) + const [firstResult] = await provider.search({ query: 'hello', isCaseSensitive: false }) + + await provider.replaceCurrentResult?.(firstResult, { + query: 'hello', + isCaseSensitive: false, + replaceQuery: 'goodbye', + }) + + expect(provider.getDocuments()[0].text).toBe('goodbye hello') + + const results = await provider.search({ query: 'hello', isCaseSensitive: false }) + await provider.replaceAllResults?.(results, { + query: 'hello', + isCaseSensitive: false, + replaceQuery: 'bye', + }) + + expect(provider.getDocuments()[0].text).toBe('goodbye bye') + }) +}) diff --git a/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts new file mode 100644 index 00000000000..c0564319e0a --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/UniversalSearch/types.ts @@ -0,0 +1,54 @@ +export type UniversalSearchStatus = 'idle' | 'loading' | 'ready' | 'error' + +export type UniversalSearchResultPayload = unknown + +export interface UniversalSearchQuery { + query: string + isCaseSensitive: boolean +} + +export interface UniversalSearchReplaceQuery extends UniversalSearchQuery { + replaceQuery: string +} + +export interface UniversalSearchResult { + id: string + label?: string + context?: string + payload?: TPayload +} + +export interface UniversalSearchProviderCapabilities { + supportsSearch: boolean + supportsReplace: boolean + supportsHighlightAll: boolean +} + +export interface UniversalSearchProvider { + id: string + capabilities: UniversalSearchProviderCapabilities + search(query: UniversalSearchQuery): Promise[]> | UniversalSearchResult[] + selectResult(result: UniversalSearchResult): Promise | void + clear(): Promise | void + replaceCurrentResult?( + result: UniversalSearchResult, + query: UniversalSearchReplaceQuery, + ): Promise[]> | UniversalSearchResult[] | Promise | void + replaceAllResults?( + results: UniversalSearchResult[], + query: UniversalSearchReplaceQuery, + ): Promise[]> | UniversalSearchResult[] | Promise | void +} + +export interface UniversalSearchControllerState { + isOpen: boolean + query: string + replaceQuery: string + results: UniversalSearchResult[] + currentResultIndex: number + status: UniversalSearchStatus + error?: string + isCaseSensitive: boolean + isReplaceMode: boolean + shouldHighlightAll: boolean +} From beaf624f65ad91184436d6bb93afab3e035e1d1d Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 3 Jun 2026 00:28:14 -0300 Subject: [PATCH 03/10] feat: Adds search for plain text editor --- .../src/Keyboard/KeyboardCommands.ts | 6 + .../src/Keyboard/KeyboardShortcut.ts | 8 +- .../src/Keyboard/getKeyboardShortcuts.ts | 30 +++ .../KeyboardShortcutsHelpModal.tsx | 1 + .../Components/NoteView/NoteView.tsx | 5 +- .../NoteView/PlainEditor/PlainEditor.tsx | 207 ++++++++++++++---- .../search/PlainEditorSearchBackdrop.tsx | 50 +++++ .../search/PlainEditorSearchContainer.tsx | 145 ++++++++++++ .../buildPlainSearchHighlightHtml.spec.ts | 68 ++++++ .../search/buildPlainSearchHighlightHtml.ts | 59 +++++ .../scrollPlainTextareaToOffset.spec.ts | 39 ++++ .../search/scrollPlainTextareaToOffset.ts | 83 +++++++ .../UniversalSearchController.spec.ts | 117 +++++++++- .../UniversalSearchController.ts | 146 +++++++++--- .../UniversalSearch/UniversalSearchShell.tsx | 29 ++- .../applyTextReplacements.spec.ts | 33 +++ .../UniversalSearch/applyTextReplacements.ts | 16 ++ .../UniversalSearch/findStringMatches.spec.ts | 23 ++ .../UniversalSearch/findStringMatches.ts | 24 ++ .../NoteView/UniversalSearch/index.ts | 4 + ...PlainEditorUniversalSearchProvider.spec.ts | 118 ++++++++++ .../PlainEditorUniversalSearchProvider.ts | 72 ++++++ .../createMockUniversalSearchProvider.ts | 112 ++++++++++ ...terUniversalSearchKeyboardHandlers.spec.ts | 64 ++++++ ...registerUniversalSearchKeyboardHandlers.ts | 99 +++++++++ .../NoteView/UniversalSearch/types.ts | 10 +- .../Components/NotesOptions/NotesOptions.tsx | 5 + .../NotesOptions/PlainNoteOptions.tsx | 38 ++++ .../Controllers/FeaturesController.ts | 27 ++- packages/web/src/stylesheets/_editor.scss | 14 ++ packages/web/src/stylesheets/_ui.scss | 40 ++++ 31 files changed, 1594 insertions(+), 98 deletions(-) create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchBackdrop.tsx create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchContainer.tsx create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/buildPlainSearchHighlightHtml.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/buildPlainSearchHighlightHtml.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/scrollPlainTextareaToOffset.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/PlainEditor/search/scrollPlainTextareaToOffset.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/applyTextReplacements.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/applyTextReplacements.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/findStringMatches.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/findStringMatches.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/PlainEditorUniversalSearchProvider.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/PlainEditorUniversalSearchProvider.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/providers/createMockUniversalSearchProvider.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/registerUniversalSearchKeyboardHandlers.spec.ts create mode 100644 packages/web/src/javascripts/Components/NoteView/UniversalSearch/registerUniversalSearchKeyboardHandlers.ts create mode 100644 packages/web/src/javascripts/Components/NotesOptions/PlainNoteOptions.tsx 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/KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal.tsx b/packages/web/src/javascripts/Components/KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal.tsx index 2fff24d5994..3df99291661 100644 --- a/packages/web/src/javascripts/Components/KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal.tsx +++ b/packages/web/src/javascripts/Components/KeyboardShortcutsHelpModal/KeyboardShortcutsHelpModal.tsx @@ -21,6 +21,7 @@ const createGroupedItems = (items: KeyboardShortcutHelpItem[]): GroupedItems => 'Super notes': [], 'Notes list': [], General: [], + Search: [], } return items.reduce((acc, item) => { acc[item.category].push(item) diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index 98f953413e1..b4ca690434e 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -46,7 +46,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' @@ -968,7 +969,7 @@ class NoteView extends AbstractComponent { )} {editorMode === 'plain' && ( - void onBlur: (event: FocusEvent) => void + onTextChange?: () => void + isSearchMode?: boolean + searchHighlightHtml?: string | null } export type PlainEditorInterface = { focus: () => void + getText: () => string + getTextarea: () => HTMLTextAreaElement | null + setSelection: (start: number, end: number, options?: { focus?: boolean; scrollIntoView?: 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, searchHighlightHtml }, + ref, + ) => { const [editorText, setEditorText] = useState() const [textareaUnloading, setTextareaUnloading] = useState(false) const [lineHeight, setLineHeight] = useState() @@ -59,12 +75,109 @@ 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 + + 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 = textarea.value.slice(0, start) + replacement + textarea.value.slice(end) + textarea.value = nextText + const nextSelection = start + replacement.length + textarea.setSelectionRange(nextSelection, nextSelection) + + await persistText(nextText) + notifyTextChange() + }, + async replaceAllRanges(ranges, replacement) { + const textarea = textareaRef.current + if (!textarea || locked || ranges.length < 1) { + return + } + + const nextText = applyTextReplacements(textarea.value, ranges, replacement) + textarea.value = nextText + textarea.setSelectionRange(nextText.length, nextText.length) + + await persistText(nextText, { bypassDebouncer: true }) + notifyTextChange() + }, + onTextChange(callback) { + textChangeObservers.current.add(callback) + return () => { + textChangeObservers.current.delete(callback) + } + }, + }), + [editorText, focusEditor, locked, notifyTextChange, persistText], + ) useEffect(() => { return () => { @@ -89,6 +202,7 @@ export const PlainEditor = forwardRef( updatedNote.noteType !== note.current.noteType ) { setEditorText(updatedNote.text) + notifyTextChange() } } @@ -103,17 +217,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 +268,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 +315,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 +356,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 +378,26 @@ export const PlainEditor = forwardRef( mutationObserver.current = observer }, - [application.keyboardService, controller], + [application.keyboardService, notifyTextChange, persistText], ) 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/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..278b381c5f0 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/PlainEditor/search/PlainEditorSearchContainer.tsx @@ -0,0 +1,145 @@ +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' +import { buildPlainSearchHighlightHtml } from './buildPlainSearchHighlightHtml' + +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, + locked: props.locked, + }) + + return new UniversalSearchController(provider) + }, [isUniversalSearchEnabled, props.locked]) + + 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, { locked: props.locked }) + }, [controller, isUniversalSearchEnabled, keyboardService, props.locked]) + + const setPlainEditorRef = useCallback((editor: PlainEditorInterface | null) => { + plainEditorRef.current = editor + }, []) + + const searchHighlightHtml = controller + ? buildPlainSearchHighlightHtml( + { + isOpen: controller.isOpen, + query: controller.query, + results: controller.results, + currentResult: controller.currentResult, + shouldHighlightAll: controller.shouldHighlightAll, + }, + () => plainEditorRef.current?.getText() ?? '', + ) + : null + + 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, + }, + () => '