From dbf8ba5f401074136eac49043815a161e3db14f9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 27 Apr 2026 10:12:13 +0200 Subject: [PATCH] feat: add editor find and replace --- docs/ABSTRACTIONS.md | 2 + docs/ARCHITECTURE.md | 5 +- docs/GETTING-STARTED.md | 2 + src-tauri/src/commands/system.rs | 4 + src-tauri/src/lib.rs | 4 +- src-tauri/src/menu.rs | 35 ++ src/App.tsx | 10 + src/components/Editor.tsx | 55 +- src/components/LinuxMenuButton.tsx | 2 + src/components/RawEditorFindBar.test.tsx | 97 ++++ src/components/RawEditorFindBar.tsx | 528 ++++++++++++++++++ src/components/RawEditorView.tsx | 68 ++- .../editor-content/EditorContentLayout.tsx | 69 ++- .../editor-content/useEditorContentModel.ts | 2 + src/hooks/appCommandCatalog.ts | 15 + src/hooks/appCommandDispatcher.ts | 6 + .../appKeyboardShortcuts.editorFind.test.ts | 84 +++ src/hooks/appKeyboardShortcuts.ts | 9 + src/hooks/commands/editorFindCommands.ts | 57 ++ src/hooks/commands/localizeCommands.ts | 2 + src/hooks/commands/noteCommands.ts | 4 + src/hooks/useAppCommands.ts | 12 + src/hooks/useCodeMirror.test.ts | 20 + src/hooks/useCodeMirror.ts | 6 +- .../useCommandRegistry.editorFind.test.ts | 76 +++ src/hooks/useCommandRegistry.ts | 7 +- src/hooks/useMenuEvents.editorFind.test.ts | 73 +++ src/hooks/useMenuEvents.ts | 28 +- src/lib/locales/en.ts | 19 + src/lib/locales/zh-Hans.ts | 19 + src/utils/editorFind.test.ts | 69 +++ src/utils/editorFind.ts | 125 +++++ src/utils/editorFindEvents.ts | 24 + tests/smoke/editor-find-replace.spec.ts | 62 ++ 34 files changed, 1561 insertions(+), 39 deletions(-) create mode 100644 src/components/RawEditorFindBar.test.tsx create mode 100644 src/components/RawEditorFindBar.tsx create mode 100644 src/hooks/appKeyboardShortcuts.editorFind.test.ts create mode 100644 src/hooks/commands/editorFindCommands.ts create mode 100644 src/hooks/useCommandRegistry.editorFind.test.ts create mode 100644 src/hooks/useMenuEvents.editorFind.test.ts create mode 100644 src/utils/editorFind.test.ts create mode 100644 src/utils/editorFind.ts create mode 100644 src/utils/editorFindEvents.ts create mode 100644 tests/smoke/editor-find-replace.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 764ed059..4c2cd518 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -566,6 +566,8 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command. While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state. +Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax. + ### Arrow Ligature Normalization Typed ASCII arrow sequences are normalized consistently in both editor modes: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d7e6d2e..5af72b3b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -793,6 +793,8 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+P / Cmd+O | Open quick open palette | | Cmd+N | Create new note | | Cmd+S | Save current note | +| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it | +| Cmd+Shift+F | Find in vault | | Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) | | Cmd+Z / Cmd+Shift+Z | Undo / Redo | | Cmd+1–9 | Switch to tab N | @@ -806,7 +808,8 @@ Shortcut routing is explicit: - `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata - `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators - `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs -- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path +- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path +- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active - `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions - `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once - Deterministic QA uses two explicit proof paths from the shared manifest: diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index e0dc5642..be929618 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -340,6 +340,8 @@ type SidebarSelection = Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. +Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`. + For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`: - `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index fa4e9d41..6249e186 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -162,6 +162,7 @@ pub struct MenuStateUpdate { has_restorable_deleted_note: Option, has_no_remote: Option, note_list_search_enabled: Option, + editor_find_enabled: Option, } #[cfg(desktop)] @@ -186,6 +187,9 @@ pub fn update_menu_state( if let Some(v) = state.note_list_search_enabled { menu::set_note_list_search_items_enabled(&app_handle, v); } + if let Some(v) = state.editor_find_enabled { + menu::set_editor_find_items_enabled(&app_handle, v); + } Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c562878..18928f09 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -176,7 +176,7 @@ fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box MenuResult { } fn build_edit_menu(app: &App) -> MenuResult { + let find_in_note = MenuItemBuilder::new("Find in Note") + .id(EDIT_FIND_IN_NOTE) + .accelerator("CmdOrCtrl+F") + .enabled(false) + .build(app)?; + let replace_in_note = MenuItemBuilder::new("Replace in Note") + .id(EDIT_REPLACE_IN_NOTE) + .enabled(false) + .build(app)?; let find_in_vault = MenuItemBuilder::new("Find in Vault") .id(EDIT_FIND_IN_VAULT) .accelerator("CmdOrCtrl+Shift+F") @@ -214,6 +230,8 @@ fn build_edit_menu(app: &App) -> MenuResult { .separator() .select_all() .separator() + .item(&find_in_note) + .item(&replace_in_note) .item(&find_in_vault) .item(&toggle_note_list_search) .item(&toggle_diff) @@ -468,6 +486,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled); } +/// Enable or disable menu items that depend on the editor being the active surface. +pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) { + set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled); +} + /// Enable or disable menu items that depend on the note list being the active surface. pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) { set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled); @@ -506,6 +529,8 @@ mod tests { FILE_NEW_TYPE, FILE_QUICK_OPEN, FILE_SAVE, + EDIT_FIND_IN_NOTE, + EDIT_REPLACE_IN_NOTE, EDIT_FIND_IN_VAULT, EDIT_TOGGLE_NOTE_LIST_SEARCH, EDIT_TOGGLE_RAW_EDITOR, @@ -564,6 +589,16 @@ mod tests { } } + #[test] + fn editor_find_dependent_ids_are_subset_of_custom_ids() { + for id in EDITOR_FIND_DEPENDENT_IDS { + assert!( + CUSTOM_IDS.contains(id), + "editor-find-dependent ID {id} not in CUSTOM_IDS" + ); + } + } + #[test] fn git_dependent_ids_are_subset_of_custom_ids() { for id in GIT_COMMIT_DEPENDENT_IDS { diff --git a/src/App.tsx b/src/App.tsx index b33d943c..d9124eef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1112,6 +1112,7 @@ function App() { const rawToggleRef = useRef<() => void>(() => {}) // Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it const diffToggleRef = useRef<() => void>(() => {}) + const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null) const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined) const { noteLayout, toggleNoteLayout } = useNoteLayout() @@ -1294,6 +1295,12 @@ function App() { () => canToggleRichEditor ? () => rawToggleRef.current() : undefined, [canToggleRichEditor], ) + const findInNoteCommand = useCallback(() => { + findInNoteRef.current?.({ replace: false }) + }, []) + const replaceInNoteCommand = useCallback(() => { + findInNoteRef.current?.({ replace: true }) + }, []) const removeActiveVaultCommand = useCallback(() => { vaultSwitcher.removeVault(vaultSwitcher.vaultPath) }, [vaultSwitcher]) @@ -1354,6 +1361,8 @@ function App() { selection: effectiveSelection, onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette, onSearch: dialogs.openSearch, + onFindInNote: findInNoteCommand, + onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand, onCreateNote: notes.handleCreateNoteImmediate, onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, @@ -1586,6 +1595,7 @@ function App() { noteLayout={noteLayout} onToggleNoteLayout={toggleNoteLayout} rawToggleRef={rawToggleRef} + findInNoteRef={findInNoteRef} diffToggleRef={diffToggleRef} canGoBack={canGoBack} canGoForward={canGoForward} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 88989184..87877768 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback, memo } from 'react' +import { useRef, useEffect, useCallback, memo, useState } from 'react' import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' @@ -19,6 +19,7 @@ import { EditorRightPanel } from './EditorRightPanel' import { EditorContent } from './EditorContent' import { FilePreview } from './FilePreview' import { schema } from './editorSchema' +import type { RawEditorFindRequest } from './RawEditorFindBar' import { applyPendingRawExitContent, resolvePendingRawExitContent, @@ -89,6 +90,8 @@ interface EditorProps { leftPanelsCollapsed?: boolean /** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */ rawToggleRef?: React.MutableRefObject<() => void> + /** Mutable ref that Editor registers editor find commands into, for shortcuts and menus. */ + findInNoteRef?: React.MutableRefObject<((options?: { replace?: boolean }) => void) | null> /** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */ diffToggleRef?: React.MutableRefObject<() => void> onFileCreated?: (relativePath: string) => void @@ -279,6 +282,43 @@ function useRegisterRawContentFlush({ }, [flushPendingRawContent, flushPendingRawContentRef]) } +function useEditorFindCommand({ + activeTab, + findInNoteRef, + handleToggleRawExclusive, + rawMode, +}: { + activeTab: Tab | null + findInNoteRef?: EditorProps['findInNoteRef'] + handleToggleRawExclusive: () => void + rawMode: boolean +}): RawEditorFindRequest | null { + const [findRequest, setFindRequest] = useState(null) + const handleFindInNote = useCallback((options: { replace?: boolean } = {}) => { + if (!activeTab || activeTab.entry.fileKind === 'binary') return + if (!rawMode) handleToggleRawExclusive() + + setFindRequest((current) => ({ + id: (current?.id ?? 0) + 1, + path: activeTab.entry.path, + replace: options.replace === true, + })) + }, [activeTab, handleToggleRawExclusive, rawMode]) + + useEffect(() => { + if (!findInNoteRef) return + + findInNoteRef.current = handleFindInNote + return () => { + if (findInNoteRef.current === handleFindInNote) { + findInNoteRef.current = null + } + } + }, [findInNoteRef, handleFindInNote]) + + return findRequest +} + function EditorLayout({ tabs, activeTab, @@ -311,6 +351,7 @@ function EditorLayout({ onUnarchiveNote, vaultPath, rawModeContent, + findRequest, rawLatestContentRef, onRenameFilename, noteLayout, @@ -371,6 +412,7 @@ function EditorLayout({ onUnarchiveNote?: (path: string) => void vaultPath?: string rawModeContent: string | null + findRequest?: RawEditorFindRequest | null rawLatestContentRef: React.MutableRefObject onRenameFilename?: (path: string, newFilenameStem: string) => void noteLayout?: NoteLayout @@ -446,6 +488,7 @@ function EditorLayout({ onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} rawModeContent={rawModeContent} + findRequest={findRequest} rawLatestContentRef={rawLatestContentRef} onRenameFilename={onRenameFilename} noteLayout={noteLayout} @@ -511,7 +554,7 @@ export const Editor = memo(function Editor(props: EditorProps) { noteLayout, onToggleNoteLayout, onFileCreated, onFileModified, onVaultChanged, isConflicted, onKeepMine, onKeepTheirs, - flushPendingRawContentRef, + flushPendingRawContentRef, findInNoteRef, locale, } = props @@ -530,6 +573,13 @@ export const Editor = memo(function Editor(props: EditorProps) { getNoteStatus, rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef, }) + const findRequest = useEditorFindCommand({ + activeTab, + findInNoteRef, + handleToggleRawExclusive, + rawMode, + }) + useRegisterRawContentFlush({ activeTab, rawLatestContentRef, @@ -571,6 +621,7 @@ export const Editor = memo(function Editor(props: EditorProps) { onUnarchiveNote={onUnarchiveNote} vaultPath={vaultPath} rawModeContent={rawModeContent} + findRequest={findRequest} rawLatestContentRef={rawLatestContentRef} onRenameFilename={onRenameFilename} noteLayout={noteLayout} diff --git a/src/components/LinuxMenuButton.tsx b/src/components/LinuxMenuButton.tsx index fd63d5b0..b939be11 100644 --- a/src/components/LinuxMenuButton.tsx +++ b/src/components/LinuxMenuButton.tsx @@ -41,6 +41,8 @@ const MENU_SECTIONS: ReadonlyArray = [ { label: 'Edit', items: [ + { kind: 'command', label: 'Find in Note', commandId: APP_COMMAND_IDS.editFindInNote }, + { kind: 'command', label: 'Replace in Note', commandId: APP_COMMAND_IDS.editReplaceInNote }, { kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault }, { kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' }, { kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff }, diff --git a/src/components/RawEditorFindBar.test.tsx b/src/components/RawEditorFindBar.test.tsx new file mode 100644 index 00000000..60f9a70d --- /dev/null +++ b/src/components/RawEditorFindBar.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { EditorView } from '@codemirror/view' +import { RawEditorFindBar } from './RawEditorFindBar' + +function renderFindBar(overrides: Partial> = {}) { + const view = { + dispatch: vi.fn(), + focus: vi.fn(), + } as unknown as EditorView + const props = { + doc: 'Alpha beta Alpha', + locale: 'en' as const, + onClose: vi.fn(), + onReplaceOpenChange: vi.fn(), + open: true, + path: '/vault/a.md', + replaceOpen: false, + request: { id: 1, path: '/vault/a.md', replace: false }, + viewRef: { current: view }, + ...overrides, + } + + render() + return { props, view } +} + +describe('RawEditorFindBar', () => { + it('finds matches and moves the CodeMirror selection', async () => { + const { view } = renderFindBar() + + const input = screen.getByTestId('raw-editor-find-input') + input.focus() + + fireEvent.change(input, { + target: { value: 'Alpha' }, + }) + + await waitFor(() => { + expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('1 / 2') + expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + selection: { anchor: 0, head: 5 }, + })) + }) + expect(input).toHaveFocus() + expect(view.focus).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Next match' })) + + await waitFor(() => { + expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('2 / 2') + expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + selection: { anchor: 11, head: 16 }, + })) + }) + }) + + it('opens replace mode and dispatches regex replacement changes', async () => { + const onReplaceOpenChange = vi.fn() + const { view } = renderFindBar({ + doc: 'foo-123 foo-456', + onReplaceOpenChange, + replaceOpen: true, + request: { id: 2, path: '/vault/a.md', replace: true }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Use regular expression' })) + fireEvent.change(screen.getByTestId('raw-editor-find-input'), { + target: { value: 'foo-(\\d+)' }, + }) + fireEvent.change(screen.getByTestId('raw-editor-replace-input'), { + target: { value: 'bar-$1' }, + }) + + await waitFor(() => { + expect(screen.getByTestId('raw-editor-find-count')).toHaveTextContent('1 / 2') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Replace' })) + + expect(view.dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ + changes: { from: 0, insert: 'bar-123', to: 7 }, + selection: { anchor: 0, head: 7 }, + })) + expect(view.focus).toHaveBeenCalled() + expect(onReplaceOpenChange).toHaveBeenCalledWith(true) + }) + + it('closes on Escape', () => { + const onClose = vi.fn() + renderFindBar({ onClose }) + + fireEvent.keyDown(screen.getByTestId('raw-editor-find-bar'), { key: 'Escape' }) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/components/RawEditorFindBar.tsx b/src/components/RawEditorFindBar.tsx new file mode 100644 index 00000000..90b4b098 --- /dev/null +++ b/src/components/RawEditorFindBar.tsx @@ -0,0 +1,528 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react' +import { EditorView } from '@codemirror/view' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { translate, type AppLocale } from '../lib/i18n' +import { + buildEditorFindReplacementChange, + buildEditorFindReplacementChanges, + clampEditorFindIndex, + findEditorMatches, + nextEditorFindIndex, + type EditorFindMatch, + type EditorFindOptions, +} from '../utils/editorFind' + +export interface RawEditorFindRequest { + id: number + path: string + replace: boolean +} + +interface RawEditorFindBarProps { + doc: string + locale?: AppLocale + onClose: () => void + onReplaceOpenChange: (open: boolean) => void + open: boolean + path: string + replaceOpen: boolean + request?: RawEditorFindRequest | null + viewRef: React.MutableRefObject +} + +function selectMatch(view: EditorView, match: EditorFindMatch, focusEditor: boolean): void { + view.dispatch({ + selection: { anchor: match.from, head: match.to }, + effects: EditorView.scrollIntoView(match.from, { y: 'center' }), + }) + if (focusEditor) view.focus() +} + +function matchStatusText( + locale: AppLocale, + error: string | null, + activeIndex: number, + matchCount: number, +): string { + if (error === 'Invalid regex') return translate(locale, 'editor.find.invalidRegex') + if (error) return translate(locale, 'editor.find.regexMustMatchText') + if (matchCount === 0) return translate(locale, 'editor.find.noMatches') + return translate(locale, 'editor.find.matchCount', { + current: clampEditorFindIndex(activeIndex, matchCount) + 1, + total: matchCount, + }) +} + +function useRequestFocus({ + inputRef, + onReplaceOpenChange, + open, + path, + request, +}: { + inputRef: React.RefObject + onReplaceOpenChange: (open: boolean) => void + open: boolean + path: string + request?: RawEditorFindRequest | null +}) { + useEffect(() => { + if (!open || !request || request.path !== path) return + if (request.replace) onReplaceOpenChange(true) + + const frameId = requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + return () => cancelAnimationFrame(frameId) + }, [inputRef, onReplaceOpenChange, open, path, request]) +} + +function focusEditorOnNextFrame(viewRef: React.MutableRefObject): void { + requestAnimationFrame(() => viewRef.current?.focus()) +} + +function closeRawEditorFind( + onClose: () => void, + viewRef: React.MutableRefObject, +): void { + onClose() + focusEditorOnNextFrame(viewRef) +} + +function handleRawEditorFindKeyDown( + event: React.KeyboardEvent, + close: () => void, + moveMatch: (direction: 1 | -1) => void, +): void { + if (event.key === 'Escape') { + event.preventDefault() + close() + return + } + if (event.key !== 'Enter') return + + event.preventDefault() + moveMatch(event.shiftKey ? -1 : 1) +} + +function handleRawEditorFindBarKeyDown( + event: React.KeyboardEvent, + close: () => void, +): void { + if (event.key !== 'Escape') return + + event.preventDefault() + close() +} + +function selectActiveEditorFindMatch( + viewRef: React.MutableRefObject, + open: boolean, + activeMatch?: EditorFindMatch, +): void { + const view = viewRef.current + if (!open || !view || !activeMatch) return + selectMatch(view, activeMatch, false) +} + +function replaceCurrentEditorFindMatch({ + activeMatch, + options, + query, + replacement, + viewRef, +}: { + activeMatch?: EditorFindMatch + options: EditorFindOptions + query: string + replacement: string + viewRef: React.MutableRefObject +}): void { + const view = viewRef.current + if (!view || !activeMatch) return + + const change = buildEditorFindReplacementChange(activeMatch, query, replacement, options) + view.dispatch({ + changes: change, + selection: { anchor: change.from, head: change.from + change.insert.length }, + effects: EditorView.scrollIntoView(change.from, { y: 'center' }), + }) + view.focus() +} + +function replaceAllEditorFindMatches({ + matches, + options, + query, + replacement, + viewRef, +}: { + matches: readonly EditorFindMatch[] + options: EditorFindOptions + query: string + replacement: string + viewRef: React.MutableRefObject +}): boolean { + const view = viewRef.current + if (!view || matches.length === 0) return false + + const changes = buildEditorFindReplacementChanges(matches, query, replacement, options) + view.dispatch({ changes }) + view.focus() + return true +} + +interface RawEditorFindController { + caseSensitive: boolean + close: () => void + findInputRef: React.RefObject + handleBarKeyDown: (event: React.KeyboardEvent) => void + handleFindChange: (event: React.ChangeEvent) => void + handleFindKeyDown: (event: React.KeyboardEvent) => void + hasMatches: boolean + moveNext: () => void + movePrevious: () => void + query: string + regex: boolean + replaceAll: () => void + replaceCurrent: () => void + replacement: string + setReplacement: (value: string) => void + status: string + toggleCaseSensitive: () => void + toggleRegex: () => void +} + +function useRawEditorFindController({ + doc, + locale = 'en', + onClose, + onReplaceOpenChange, + open, + path, + request, + viewRef, +}: Omit): RawEditorFindController { + const inputRef = useRef(null) + const [query, setQuery] = useState('') + const [replacement, setReplacement] = useState('') + const [regex, setRegex] = useState(false) + const [caseSensitive, setCaseSensitive] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + const options = useMemo(() => ({ caseSensitive, regex }), [caseSensitive, regex]) + const result = useMemo(() => findEditorMatches(doc, query, options), [doc, options, query]) + const clampedActiveIndex = clampEditorFindIndex(activeIndex, result.matches.length) + const activeMatch = result.matches[clampedActiveIndex] + const status = matchStatusText(locale, result.error, clampedActiveIndex, result.matches.length) + const hasMatches = result.matches.length > 0 && !result.error + + useRequestFocus({ inputRef, onReplaceOpenChange, open, path, request }) + + useEffect(() => { + selectActiveEditorFindMatch(viewRef, open, activeMatch) + }, [activeMatch, open, viewRef]) + + const moveMatch = useCallback((direction: 1 | -1) => { + setActiveIndex((current) => nextEditorFindIndex(current, result.matches.length, direction)) + }, [result.matches.length]) + const movePrevious = useCallback(() => moveMatch(-1), [moveMatch]) + const moveNext = useCallback(() => moveMatch(1), [moveMatch]) + const handleFindChange = useCallback((event: React.ChangeEvent) => { + setQuery(event.target.value) + setActiveIndex(0) + }, []) + + const close = useCallback(() => closeRawEditorFind(onClose, viewRef), [onClose, viewRef]) + + const handleFindKeyDown = useCallback((event: React.KeyboardEvent) => { + handleRawEditorFindKeyDown(event, close, moveMatch) + }, [close, moveMatch]) + + const handleBarKeyDown = useCallback((event: React.KeyboardEvent) => { + handleRawEditorFindBarKeyDown(event, close) + }, [close]) + + const replaceCurrent = useCallback(() => { + replaceCurrentEditorFindMatch({ activeMatch, options, query, replacement, viewRef }) + }, [activeMatch, options, query, replacement, viewRef]) + + const replaceAll = useCallback(() => { + if (replaceAllEditorFindMatches({ + matches: result.matches, + options, + query, + replacement, + viewRef, + })) { + setActiveIndex(0) + } + }, [options, query, replacement, result.matches, viewRef]) + + return { + caseSensitive, + close, + findInputRef: inputRef, + handleBarKeyDown, + handleFindChange, + handleFindKeyDown, + hasMatches, + moveNext, + movePrevious, + query, + regex, + replaceAll, + replaceCurrent, + replacement, + setReplacement, + status, + toggleCaseSensitive: () => setCaseSensitive((value) => !value), + toggleRegex: () => setRegex((value) => !value), + } +} + +type FindControlsProps = Pick< + RawEditorFindController, + | 'caseSensitive' + | 'close' + | 'findInputRef' + | 'handleFindChange' + | 'handleFindKeyDown' + | 'hasMatches' + | 'moveNext' + | 'movePrevious' + | 'query' + | 'regex' + | 'status' + | 'toggleCaseSensitive' + | 'toggleRegex' +> & { + locale: AppLocale + onReplaceOpenChange: (open: boolean) => void + replaceOpen: boolean +} + +function FindControls({ + caseSensitive, + close, + findInputRef, + handleFindChange, + handleFindKeyDown, + hasMatches, + locale, + moveNext, + movePrevious, + onReplaceOpenChange, + query, + regex, + replaceOpen, + status, + toggleCaseSensitive, + toggleRegex, +}: FindControlsProps) { + return ( +
+ + + + {status} + + + + + + +
+ ) +} + +type ReplaceControlsProps = Pick< + RawEditorFindController, + 'hasMatches' | 'replaceAll' | 'replaceCurrent' | 'replacement' | 'setReplacement' +> & { + locale: AppLocale +} + +function ReplaceControls({ + hasMatches, + locale, + replaceAll, + replaceCurrent, + replacement, + setReplacement, +}: ReplaceControlsProps) { + return ( +
+ setReplacement(event.target.value)} + className="h-7 min-w-[12rem] flex-1 rounded px-2 text-xs" + data-testid="raw-editor-replace-input" + /> + + +
+ ) +} + +export function RawEditorFindBar(props: RawEditorFindBarProps) { + const { locale = 'en', onReplaceOpenChange, open, replaceOpen } = props + const controller = useRawEditorFindController(props) + const { + caseSensitive, + close, + findInputRef, + handleBarKeyDown, + handleFindChange, + handleFindKeyDown, + hasMatches, + moveNext, + movePrevious, + query, + regex, + replaceAll, + replaceCurrent, + replacement, + setReplacement, + status, + toggleCaseSensitive, + toggleRegex, + } = controller + + if (!open) return null + + return ( +
+ + {replaceOpen && ( + + )} +
+ ) +} diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index e19068a6..12a65a52 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -16,6 +16,7 @@ import { import { useCodeMirror } from '../hooks/useCodeMirror' import type { VaultEntry } from '../types' import { translate, type AppLocale } from '../lib/i18n' +import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar' export interface RawEditorViewProps { content: string @@ -28,6 +29,7 @@ export interface RawEditorViewProps { * Allows the parent to flush debounced content before unmount. */ latestContentRef?: React.MutableRefObject locale?: AppLocale + findRequest?: RawEditorFindRequest | null } const DEBOUNCE_MS = 500 @@ -332,32 +334,76 @@ function useRawEditorWikilinkInsertion({ useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef]) } -export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en' }: RawEditorViewProps) { +export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) { const containerRef = useRef(null) + const [rawDoc, setRawDoc] = useState(content) + const [findOpen, setFindOpen] = useState(false) + const [replaceOpen, setReplaceOpen] = useState(false) const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path }) - const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath }) + const { + autocomplete, + handleAutocompleteKey, + handleCursorActivity, + handleEscape: handleAutocompleteEscape, + handleItemHover, + insertWikilinkRef, + setAutocomplete, + } = useRawEditorAutocompleteController({ entries, vaultPath }) + const handleDocChange = useCallback((doc: string) => { + setRawDoc(doc) + pendingChanges.handleDocChange(doc) + }, [pendingChanges]) + const handleEscape = useCallback(() => { + if (handleAutocompleteEscape()) return true + if (!findOpen) return false + + setFindOpen(false) + return true + }, [findOpen, handleAutocompleteEscape]) const viewRef = useCodeMirror(containerRef, content, { - onDocChange: pendingChanges.handleDocChange, - onCursorActivity: autocompleteController.handleCursorActivity, + onDocChange: handleDocChange, + onCursorActivity: handleCursorActivity, onSave: pendingChanges.handleSave, - onEscape: autocompleteController.handleEscape, + onEscape: handleEscape, }) useRawEditorWikilinkInsertion({ debounceRef: pendingChanges.debounceRef, - insertWikilinkRef: autocompleteController.insertWikilinkRef, + insertWikilinkRef, latestDocRef: pendingChanges.latestDocRef, onContentChangeRef: pendingChanges.onContentChangeRef, pathRef: pendingChanges.pathRef, - setAutocomplete: autocompleteController.setAutocomplete, + setAutocomplete, viewRef, }) - const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window) + useEffect(() => { + setRawDoc(content) + }, [content]) + + useEffect(() => { + if (!findRequest || findRequest.path !== path) return + setAutocomplete(null) + setFindOpen(true) + setReplaceOpen(findRequest.replace) + }, [findRequest, path, setAutocomplete]) + + const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window) return ( -
+
+ setFindOpen(false)} + onReplaceOpenChange={setReplaceOpen} + open={findOpen} + path={path} + replaceOpen={replaceOpen} + request={findRequest} + viewRef={viewRef} + />
diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index fc7e4afc..f2f1b74c 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -1,6 +1,8 @@ import type React from 'react' +import { useCallback, useEffect, useRef } from 'react' import { cn } from '@/lib/utils' import { translate, type AppLocale } from '../../lib/i18n' +import { dispatchEditorFindAvailability } from '../../utils/editorFindEvents' import { DiffView } from '../DiffView' import { BreadcrumbBar } from '../BreadcrumbBar' import { ArchivedNoteBanner } from '../ArchivedNoteBanner' @@ -69,6 +71,7 @@ function DiffModeView({ diffContent, locale = 'en', onToggleDiff }: { diffConten function RawModeEditorSection({ activeTab, entries, + findRequest, rawMode, rawModeContent, onRawContentChange, @@ -78,7 +81,7 @@ function RawModeEditorSection({ locale, }: Pick< EditorContentModel, - 'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath' + 'activeTab' | 'entries' | 'findRequest' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath' > & { rawMode: boolean locale?: AppLocale @@ -86,17 +89,22 @@ function RawModeEditorSection({ if (!rawMode || !activeTab) return null return ( - {})} - onSave={onSave ?? (() => {})} - latestContentRef={rawLatestContentRef} - vaultPath={vaultPath} - locale={locale} - /> + +
+ {})} + onSave={onSave ?? (() => {})} + latestContentRef={rawLatestContentRef} + vaultPath={vaultPath} + locale={locale} + /> +
+
) } @@ -205,7 +213,7 @@ function EditorCanvas({ if (!showEditor) return null return ( -
+
+
+ ) +} + +function EditorFindScope({ + children, + className, + style, +}: { + children: React.ReactNode + className?: string + style?: React.CSSProperties +}) { + const scopeRef = useRef(null) + const syncAvailability = useCallback(() => { + const activeElement = document.activeElement + const enabled = activeElement instanceof Node + && scopeRef.current?.contains(activeElement) === true + dispatchEditorFindAvailability(enabled) + }, []) + + useEffect(() => () => dispatchEditorFindAvailability(false), []) + + return ( +
dispatchEditorFindAvailability(true)} + onBlurCapture={() => requestAnimationFrame(syncAvailability)} + style={style} + > + {children}
) } @@ -249,6 +290,7 @@ export function EditorContentLayout(model: EditorContentModel) { rawLatestContentRef, rawModeContent, noteLayout, + findRequest, locale, } = model const rootClassName = cn( @@ -311,6 +353,7 @@ export function EditorContentLayout(model: EditorContentModel) { void vaultPath?: string rawModeContent?: string | null + findRequest?: RawEditorFindRequest | null rawLatestContentRef?: React.MutableRefObject onRenameFilename?: (path: string, newFilenameStem: string) => void noteLayout?: NoteLayout diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 7ecd1e84..481df84c 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -9,6 +9,8 @@ export const APP_COMMAND_IDS = { fileNewType: 'file-new-type', fileQuickOpen: 'file-quick-open', fileSave: 'file-save', + editFindInNote: 'edit-find-in-note', + editReplaceInNote: 'edit-replace-in-note', editFindInVault: 'edit-find-in-vault', editToggleRawEditor: 'edit-toggle-raw-editor', editToggleDiff: 'edit-toggle-diff', @@ -80,6 +82,8 @@ type SimpleHandlerKey = | 'onCreateType' | 'onQuickOpen' | 'onSave' + | 'onFindInNote' + | 'onReplaceInNote' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -167,6 +171,16 @@ export const APP_COMMAND_DEFINITIONS: Record menuOwned: true, shortcut: { combo: 'command-or-ctrl', key: 's', code: 'KeyS', display: '⌘S' }, }, + [APP_COMMAND_IDS.editFindInNote]: { + route: { kind: 'handler', handler: 'onFindInNote' }, + menuOwned: true, + preferredShortcutQaMode: 'renderer-shortcut-event', + shortcut: { combo: 'command-or-ctrl', key: 'f', code: 'KeyF', display: '⌘F' }, + }, + [APP_COMMAND_IDS.editReplaceInNote]: { + route: { kind: 'handler', handler: 'onReplaceInNote' }, + menuOwned: true, + }, [APP_COMMAND_IDS.editFindInVault]: { route: { kind: 'handler', handler: 'onSearch' }, menuOwned: true, @@ -344,6 +358,7 @@ const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set([ APP_COMMAND_IDS.fileNewNote, APP_COMMAND_IDS.fileQuickOpen, APP_COMMAND_IDS.fileSave, + APP_COMMAND_IDS.editFindInNote, APP_COMMAND_IDS.editFindInVault, APP_COMMAND_IDS.viewToggleAiChat, APP_COMMAND_IDS.viewCommandPalette, diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index a71a8ab2..d59c43da 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -43,6 +43,8 @@ export interface AppCommandHandlers { onToggleFavorite?: (path: string) => void onArchiveNote: (path: string) => void onDeleteNote: (path: string) => void + onFindInNote?: () => void + onReplaceInNote?: () => void onSearch: () => void onToggleRawEditor?: () => void onToggleDiff?: () => void @@ -76,6 +78,8 @@ type SimpleHandlerKey = keyof Pick< | 'onCreateType' | 'onQuickOpen' | 'onSave' + | 'onFindInNote' + | 'onReplaceInNote' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -114,6 +118,8 @@ const SIMPLE_HANDLER_EXECUTORS: Record handlers.onCreateType?.(), onQuickOpen: (handlers) => handlers.onQuickOpen(), onSave: (handlers) => handlers.onSave(), + onFindInNote: (handlers) => handlers.onFindInNote?.(), + onReplaceInNote: (handlers) => handlers.onReplaceInNote?.(), onSearch: (handlers) => handlers.onSearch(), onToggleRawEditor: (handlers) => handlers.onToggleRawEditor?.(), onToggleDiff: (handlers) => handlers.onToggleDiff?.(), diff --git a/src/hooks/appKeyboardShortcuts.editorFind.test.ts b/src/hooks/appKeyboardShortcuts.editorFind.test.ts new file mode 100644 index 00000000..fb2c8567 --- /dev/null +++ b/src/hooks/appKeyboardShortcuts.editorFind.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest' +import { handleAppKeyboardEvent, type KeyboardActions } from './appKeyboardShortcuts' + +function makeActions(): KeyboardActions { + return { + activeTabPathRef: { current: '/vault/a.md' }, + multiSelectionCommandRef: { current: null }, + onArchiveNote: vi.fn(), + onCommandPalette: vi.fn(), + onCreateNote: vi.fn(), + onDeleteNote: vi.fn(), + onFindInNote: vi.fn(), + onGoBack: vi.fn(), + onGoForward: vi.fn(), + onOpenInNewWindow: vi.fn(), + onOpenSettings: vi.fn(), + onQuickOpen: vi.fn(), + onReplaceInNote: vi.fn(), + onSave: vi.fn(), + onSearch: vi.fn(), + onSetViewMode: vi.fn(), + onToggleFavorite: vi.fn(), + onToggleInspector: vi.fn(), + onToggleOrganized: vi.fn(), + onToggleRawEditor: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onZoomReset: vi.fn(), + } +} + +function commandF(): KeyboardEvent { + return new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + code: 'KeyF', + key: 'f', + metaKey: true, + }) +} + +function withFocusedElement(element: HTMLElement, test: () => void): void { + document.body.appendChild(element) + element.focus() + try { + test() + } finally { + element.remove() + } +} + +describe('editor find shortcut routing', () => { + it('runs Cmd+F when focus is inside the editor scope', () => { + const actions = makeActions() + const scope = document.createElement('div') + scope.setAttribute('data-editor-find-scope', 'true') + const editor = document.createElement('div') + editor.tabIndex = 0 + scope.appendChild(editor) + + withFocusedElement(scope, () => { + editor.focus() + const event = commandF() + handleAppKeyboardEvent(actions, event) + + expect(event.defaultPrevented).toBe(true) + expect(actions.onFindInNote).toHaveBeenCalledOnce() + }) + }) + + it('yields Cmd+F outside the editor scope so note-list search can handle it', () => { + const actions = makeActions() + const noteList = document.createElement('div') + noteList.tabIndex = 0 + + withFocusedElement(noteList, () => { + const event = commandF() + handleAppKeyboardEvent(actions, event) + + expect(event.defaultPrevented).toBe(false) + expect(actions.onFindInNote).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/appKeyboardShortcuts.ts b/src/hooks/appKeyboardShortcuts.ts index a3264c9d..2acb663d 100644 --- a/src/hooks/appKeyboardShortcuts.ts +++ b/src/hooks/appKeyboardShortcuts.ts @@ -15,6 +15,8 @@ export type KeyboardActions = Pick< | 'onSearch' | 'onCreateNote' | 'onSave' + | 'onFindInNote' + | 'onReplaceInNote' | 'onOpenSettings' | 'onDeleteNote' | 'onArchiveNote' @@ -47,9 +49,16 @@ function isTextInputFocused(): boolean { return active.isContentEditable || active.closest('[contenteditable="true"]') !== null } +function isEditorFindScopeFocused(): boolean { + const active = document.activeElement + if (!(active instanceof HTMLElement)) return false + return active.closest('[data-editor-find-scope="true"]') !== null +} + export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) { const commandId = findShortcutCommandIdForEvent(event) if (commandId === null) return + if (commandId === APP_COMMAND_IDS.editFindInNote && !isEditorFindScopeFocused()) return const textInputFocused = isTextInputFocused() if (textInputFocused) { diff --git a/src/hooks/commands/editorFindCommands.ts b/src/hooks/commands/editorFindCommands.ts new file mode 100644 index 00000000..fe73cacf --- /dev/null +++ b/src/hooks/commands/editorFindCommands.ts @@ -0,0 +1,57 @@ +import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog' +import type { CommandAction } from './types' + +interface EditorFindCommandsConfig { + activeFileKind?: 'markdown' | 'text' | 'binary' + hasActiveNote: boolean + onFindInNote?: () => void + onReplaceInNote?: () => void +} + +interface EditorFindCommandConfig { + execute?: () => void + id: string + keywords: string[] + label: string + shortcut?: string + searchEnabled: boolean +} + +function canSearchCurrentNote(config: EditorFindCommandsConfig): boolean { + const activeFileKind = config.activeFileKind ?? 'markdown' + return config.hasActiveNote && activeFileKind !== 'binary' +} + +function createEditorFindCommand(config: EditorFindCommandConfig): CommandAction { + return { + id: config.id, + label: config.label, + group: 'Note', + shortcut: config.shortcut, + keywords: config.keywords, + enabled: config.searchEnabled && !!config.execute, + execute: config.execute, + } +} + +export function buildEditorFindCommands(config: EditorFindCommandsConfig): CommandAction[] { + const searchEnabled = canSearchCurrentNote(config) + + return [ + createEditorFindCommand({ + id: 'find-in-note', + label: 'Find in Note', + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.editFindInNote), + keywords: ['find', 'search', 'current', 'editor'], + searchEnabled, + execute: config.onFindInNote, + }), + createEditorFindCommand({ + id: 'replace-in-note', + label: 'Replace in Note', + keywords: ['find', 'replace', 'regex', 'current', 'editor'], + searchEnabled, + execute: config.onReplaceInNote, + }), + ] +} diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index 53bab0a3..77c4687d 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -28,6 +28,8 @@ const STATIC_LABEL_KEYS: Partial> = { 'create-note': 'command.note.newNote', 'create-type': 'command.note.newType', 'save-note': 'command.note.saveNote', + 'find-in-note': 'command.note.findInNote', + 'replace-in-note': 'command.note.replaceInNote', 'delete-note': 'command.note.deleteNote', 'restore-deleted-note': 'command.note.restoreDeleted', 'set-note-icon': 'command.note.setIcon', diff --git a/src/hooks/commands/noteCommands.ts b/src/hooks/commands/noteCommands.ts index 70de0fbb..15e9d6d1 100644 --- a/src/hooks/commands/noteCommands.ts +++ b/src/hooks/commands/noteCommands.ts @@ -1,4 +1,5 @@ import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog' +import { buildEditorFindCommands } from './editorFindCommands' import type { CommandAction } from './types' interface NoteCommandsConfig { @@ -10,6 +11,8 @@ interface NoteCommandsConfig { onCreateNote: () => void onCreateType?: () => void onSave: () => void + onFindInNote?: () => void + onReplaceInNote?: () => void onDeleteNote: (path: string) => void onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void @@ -84,6 +87,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] { enabled: config.hasActiveNote, execute: config.onSave, }), + ...buildEditorFindCommands(config), ] } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 03109d2f..6b5df1f7 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -24,6 +24,8 @@ interface AppCommandsConfig { onQuickOpen: () => void onCommandPalette: () => void onSearch: () => void + onFindInNote?: () => void + onReplaceInNote?: () => void onCreateNote: () => void onCreateNoteOfType: (type: string) => void onSave: () => void @@ -139,6 +141,8 @@ type CommandRegistryCoreActions = Pick< | 'onCreateNote' | 'onCreateNoteOfType' | 'onSave' + | 'onFindInNote' + | 'onReplaceInNote' | 'onOpenSettings' | 'onOpenFeedback' | 'onDeleteNote' @@ -219,6 +223,8 @@ function createKeyboardActions( onQuickOpen: config.onQuickOpen, onCommandPalette: config.onCommandPalette, onSearch: config.onSearch, + onFindInNote: config.onFindInNote, + onReplaceInNote: config.onReplaceInNote, onCreateNote: config.onCreateNote, onSave: config.onSave, onOpenSettings: config.onOpenSettings, @@ -269,6 +275,8 @@ function createMenuEventActionHandlers( | 'onZoomOut' | 'onZoomReset' | 'onDeleteNote' + | 'onFindInNote' + | 'onReplaceInNote' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -292,6 +300,8 @@ function createMenuEventActionHandlers( onZoomOut: config.onZoomOut, onZoomReset: config.onZoomReset, onDeleteNote: config.onDeleteNote, + onFindInNote: config.onFindInNote, + onReplaceInNote: config.onReplaceInNote, onSearch: config.onSearch, onToggleRawEditor: config.onToggleRawEditor, onToggleDiff: config.onToggleDiff, @@ -407,6 +417,8 @@ function createCommandRegistryCoreConfig( onToggleInspector: config.onToggleInspector, onToggleDiff: config.onToggleDiff, onToggleRawEditor: config.onToggleRawEditor, + onFindInNote: config.onFindInNote, + onReplaceInNote: config.onReplaceInNote, noteLayout: config.noteLayout, onToggleNoteLayout: config.onToggleNoteLayout, onToggleAIChat: config.onToggleAIChat, diff --git a/src/hooks/useCodeMirror.test.ts b/src/hooks/useCodeMirror.test.ts index 288f8c48..1adcf83a 100644 --- a/src/hooks/useCodeMirror.test.ts +++ b/src/hooks/useCodeMirror.test.ts @@ -97,6 +97,26 @@ describe('useCodeMirror', () => { expect(onDocChange).not.toHaveBeenCalled() }) + it('lets app Escape handling run before the CodeMirror default keymap', () => { + const ref = { current: container } + const onEscape = vi.fn(() => true) + const { result } = renderHook(() => + useCodeMirror(ref, 'hello', { ...noopCallbacks, onEscape }), + ) + const view = result.current.current! + + act(() => { + view.focus() + view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Escape', + })) + }) + + expect(onEscape).toHaveBeenCalledOnce() + }) + it('does not sync when content matches current editor state', () => { const ref = { current: container } const { result, rerender } = renderHook( diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index ef7ab3e5..99c79af0 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -1,6 +1,6 @@ import { useRef, useEffect } from 'react' import { EditorView, lineNumbers, highlightActiveLine, keymap } from '@codemirror/view' -import { EditorState } from '@codemirror/state' +import { EditorState, Prec } from '@codemirror/state' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight' import { markdownLanguage } from '../extensions/markdownHighlight' @@ -70,13 +70,13 @@ function buildBaseTheme() { } function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) { - return keymap.of([{ + return Prec.highest(keymap.of([{ key: 'Mod-s', run: () => { callbacks.current.onSave(); return true }, }, { key: 'Escape', run: () => callbacks.current.onEscape(), - }]) + }])) } function buildArrowLigaturesExtension() { diff --git a/src/hooks/useCommandRegistry.editorFind.test.ts b/src/hooks/useCommandRegistry.editorFind.test.ts new file mode 100644 index 00000000..80c1989c --- /dev/null +++ b/src/hooks/useCommandRegistry.editorFind.test.ts @@ -0,0 +1,76 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useCommandRegistry } from './useCommandRegistry' + +function makeConfig(overrides: Record = {}) { + return { + activeNoteModified: false, + activeTabPath: '/vault/a.md', + entries: [{ path: '/vault/a.md', title: 'A', fileKind: 'markdown' }], + modifiedCount: 0, + onArchiveNote: vi.fn(), + onCreateNote: vi.fn(), + onCreateNoteOfType: vi.fn(), + onDeleteNote: vi.fn(), + onFindInNote: vi.fn(), + onOpenSettings: vi.fn(), + onQuickOpen: vi.fn(), + onReplaceInNote: vi.fn(), + onSave: vi.fn(), + onSelect: vi.fn(), + onSetViewMode: vi.fn(), + onToggleInspector: vi.fn(), + onUnarchiveNote: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onZoomReset: vi.fn(), + zoomLevel: 100, + ...overrides, + } +} + +describe('useCommandRegistry editor find commands', () => { + it('exposes note-scoped find and replace commands', () => { + const config = makeConfig() + const { result } = renderHook(() => useCommandRegistry(config)) + + const find = result.current.find((command) => command.id === 'find-in-note') + const replace = result.current.find((command) => command.id === 'replace-in-note') + + expect(find).toMatchObject({ + enabled: true, + group: 'Note', + label: 'Find in Note', + shortcut: 'Ctrl+F', + }) + expect(replace).toMatchObject({ + enabled: true, + group: 'Note', + label: 'Replace in Note', + }) + + find?.execute() + replace?.execute() + expect(config.onFindInNote).toHaveBeenCalledOnce() + expect(config.onReplaceInNote).toHaveBeenCalledOnce() + }) + + it('disables note find for binary files', () => { + const config = makeConfig({ + activeTabPath: '/vault/photo.png', + entries: [{ path: '/vault/photo.png', title: 'photo.png', fileKind: 'binary' }], + }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(result.current.find((command) => command.id === 'find-in-note')?.enabled).toBe(false) + expect(result.current.find((command) => command.id === 'replace-in-note')?.enabled).toBe(false) + }) + + it('disables note find until a note is active', () => { + const config = makeConfig({ activeTabPath: null }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(result.current.find((command) => command.id === 'find-in-note')?.enabled).toBe(false) + expect(result.current.find((command) => command.id === 'replace-in-note')?.enabled).toBe(false) + }) +}) diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 3da62c6c..30c2e2c2 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -83,6 +83,8 @@ interface CommandRegistryConfig { onToggleInspector: () => void onToggleDiff?: () => void onToggleRawEditor?: () => void + onFindInNote?: () => void + onReplaceInNote?: () => void noteLayout?: NoteLayout onToggleNoteLayout?: () => void onToggleAIChat?: () => void @@ -116,7 +118,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com activeTabPath, entries, modifiedCount, onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, + onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault, activeNoteModified, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath, @@ -175,6 +177,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const noteCommands = useMemo(() => buildNoteCommands({ hasActiveNote, activeTabPath, activeFileKind: activeEntry?.fileKind ?? 'markdown', isArchived, onCreateNote, onCreateType, onSave, + onFindInNote, onReplaceInNote, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, @@ -184,7 +187,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onRestoreDeletedNote, canRestoreDeletedNote, }), [ hasActiveNote, activeTabPath, activeEntry?.fileKind, isArchived, - onCreateNote, onCreateType, onSave, onDeleteNote, onArchiveNote, onUnarchiveNote, + onCreateNote, onCreateType, onSave, onFindInNote, onReplaceInNote, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, diff --git a/src/hooks/useMenuEvents.editorFind.test.ts b/src/hooks/useMenuEvents.editorFind.test.ts new file mode 100644 index 00000000..c291c1c1 --- /dev/null +++ b/src/hooks/useMenuEvents.editorFind.test.ts @@ -0,0 +1,73 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useMenuEvents, type MenuEventHandlers } from './useMenuEvents' + +const isTauriMock = vi.fn(() => false) +const listenMock = vi.fn() +const invokeMock = vi.fn().mockResolvedValue(undefined) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => isTauriMock(), +})) + +vi.mock('@tauri-apps/api/event', () => ({ + listen: (...args: unknown[]) => listenMock(...args), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})) + +function makeHandlers(): MenuEventHandlers { + return { + activeTabPath: '/vault/a.md', + activeTabPathRef: { current: '/vault/a.md' }, + hasNoRemote: false, + hasRestorableDeletedNote: false, + multiSelectionCommandRef: { current: null }, + onArchiveNote: vi.fn(), + onCommandPalette: vi.fn(), + onCreateNote: vi.fn(), + onDeleteNote: vi.fn(), + onFindInNote: vi.fn(), + onOpenSettings: vi.fn(), + onQuickOpen: vi.fn(), + onReplaceInNote: vi.fn(), + onSave: vi.fn(), + onSearch: vi.fn(), + onSetViewMode: vi.fn(), + onToggleInspector: vi.fn(), + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onZoomReset: vi.fn(), + } +} + +describe('useMenuEvents editor find state', () => { + beforeEach(() => { + vi.clearAllMocks() + isTauriMock.mockReturnValue(true) + listenMock.mockResolvedValue(vi.fn()) + }) + + it('syncs editor find availability into the native menu state', async () => { + renderHook(() => useMenuEvents(makeHandlers())) + await vi.dynamicImportSettled() + + expect(invokeMock).toHaveBeenCalledWith('update_menu_state', expect.objectContaining({ + state: expect.objectContaining({ editorFindEnabled: false }), + })) + + act(() => { + window.dispatchEvent(new CustomEvent('laputa:editor-find-availability', { + detail: { enabled: true }, + })) + }) + + await waitFor(() => { + expect(invokeMock).toHaveBeenLastCalledWith('update_menu_state', expect.objectContaining({ + state: expect.objectContaining({ editorFindEnabled: true }), + })) + }) + }) +}) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 66fb4dbb..656d72fd 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -11,6 +11,10 @@ import { dispatchNoteListSearchToggle, readNoteListSearchAvailability, } from '../utils/noteListSearchEvents' +import { + EDITOR_FIND_AVAILABILITY_EVENT, + readEditorFindAvailability, +} from '../utils/editorFindEvents' const NOTE_LIST_SEARCH_MENU_ID = 'edit-toggle-note-list-search' @@ -31,6 +35,7 @@ interface MenuStatePayload { hasRestorableDeletedNote?: boolean hasNoRemote?: boolean noteListSearchEnabled?: boolean + editorFindEnabled?: boolean } function readCustomEventDetail(event: Event): string | null { @@ -134,18 +139,21 @@ function useNativeMenuStateSync(state: MenuStatePayload) { }, [state]) } -function useNoteListSearchMenuState() { +function useAvailabilityMenuState( + eventName: string, + readAvailability: (event: Event) => boolean | null, +) { const [enabled, setEnabled] = useState(false) useEffect(() => { const handleAvailabilityEvent = (event: Event) => { - const nextEnabled = readNoteListSearchAvailability(event) + const nextEnabled = readAvailability(event) if (nextEnabled !== null) setEnabled(nextEnabled) } - window.addEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent) - return () => window.removeEventListener(NOTE_LIST_SEARCH_AVAILABILITY_EVENT, handleAvailabilityEvent) - }, []) + window.addEventListener(eventName, handleAvailabilityEvent) + return () => window.removeEventListener(eventName, handleAvailabilityEvent) + }, [eventName, readAvailability]) return enabled } @@ -163,7 +171,14 @@ export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void { /** Listen for native macOS menu events and dispatch them to the appropriate handlers. */ export function useMenuEvents(handlers: MenuEventHandlers) { const ref = useRef(handlers) - const noteListSearchEnabled = useNoteListSearchMenuState() + const noteListSearchEnabled = useAvailabilityMenuState( + NOTE_LIST_SEARCH_AVAILABILITY_EVENT, + readNoteListSearchAvailability, + ) + const editorFindEnabled = useAvailabilityMenuState( + EDITOR_FIND_AVAILABILITY_EVENT, + readEditorFindAvailability, + ) const hasActiveNote = handlers.activeTabPath !== null const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined @@ -184,5 +199,6 @@ export function useMenuEvents(handlers: MenuEventHandlers) { hasRestorableDeletedNote, hasNoRemote, noteListSearchEnabled, + editorFindEnabled, }) } diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts index c42b7600..6feef184 100644 --- a/src/lib/locales/en.ts +++ b/src/lib/locales/en.ts @@ -38,6 +38,8 @@ export const EN_TRANSLATIONS = { 'command.note.newType': 'New Type', 'command.note.newTypedNote': 'New {type}', 'command.note.saveNote': 'Save Note', + 'command.note.findInNote': 'Find in Note', + 'command.note.replaceInNote': 'Replace in Note', 'command.note.deleteNote': 'Delete Note', 'command.note.archiveNote': 'Archive Note', 'command.note.unarchiveNote': 'Unarchive Note', @@ -227,6 +229,23 @@ export const EN_TRANSLATIONS = { 'editor.empty.selectNote': 'Select a note to start editing', 'editor.empty.shortcuts': '{quickOpen} to search · {newNote} to create', 'editor.raw.label': 'Raw editor', + 'editor.find.findLabel': 'Find', + 'editor.find.findPlaceholder': 'Find', + 'editor.find.replaceLabel': 'Replace', + 'editor.find.replacePlaceholder': 'Replace', + 'editor.find.matchCount': '{current} / {total}', + 'editor.find.noMatches': 'No matches', + 'editor.find.invalidRegex': 'Invalid regex', + 'editor.find.regexMustMatchText': 'Regex must match text', + 'editor.find.previousMatch': 'Previous match', + 'editor.find.nextMatch': 'Next match', + 'editor.find.showReplace': 'Show replace', + 'editor.find.hideReplace': 'Hide replace', + 'editor.find.regex': 'Use regular expression', + 'editor.find.matchCase': 'Match case', + 'editor.find.close': 'Close find', + 'editor.find.replace': 'Replace', + 'editor.find.replaceAll': 'All', 'editor.toolbar.rawReturn': 'Return to the editor', 'editor.toolbar.rawOpen': 'Open the raw editor', 'editor.toolbar.centerLayout': 'Switch to centered note layout', diff --git a/src/lib/locales/zh-Hans.ts b/src/lib/locales/zh-Hans.ts index a054ed86..e462972d 100644 --- a/src/lib/locales/zh-Hans.ts +++ b/src/lib/locales/zh-Hans.ts @@ -40,6 +40,8 @@ export const ZH_HANS_TRANSLATIONS = { 'command.note.newType': '新建类型', 'command.note.newTypedNote': '新建{type}', 'command.note.saveNote': '保存笔记', + 'command.note.findInNote': '在笔记中查找', + 'command.note.replaceInNote': '在笔记中替换', 'command.note.deleteNote': '删除笔记', 'command.note.archiveNote': '归档笔记', 'command.note.unarchiveNote': '取消归档笔记', @@ -229,6 +231,23 @@ export const ZH_HANS_TRANSLATIONS = { 'editor.empty.selectNote': '选择一条笔记开始编辑', 'editor.empty.shortcuts': '{quickOpen} 搜索 · {newNote} 创建', 'editor.raw.label': '源码编辑器', + 'editor.find.findLabel': '查找', + 'editor.find.findPlaceholder': '查找', + 'editor.find.replaceLabel': '替换', + 'editor.find.replacePlaceholder': '替换', + 'editor.find.matchCount': '{current} / {total}', + 'editor.find.noMatches': '无匹配', + 'editor.find.invalidRegex': '无效正则', + 'editor.find.regexMustMatchText': '正则必须匹配文本', + 'editor.find.previousMatch': '上一个匹配', + 'editor.find.nextMatch': '下一个匹配', + 'editor.find.showReplace': '显示替换', + 'editor.find.hideReplace': '隐藏替换', + 'editor.find.regex': '使用正则表达式', + 'editor.find.matchCase': '区分大小写', + 'editor.find.close': '关闭查找', + 'editor.find.replace': '替换', + 'editor.find.replaceAll': '全部', 'editor.toolbar.rawReturn': '返回编辑器', 'editor.toolbar.rawOpen': '打开源码编辑器', 'editor.toolbar.centerLayout': '切换到居中笔记布局', diff --git a/src/utils/editorFind.test.ts b/src/utils/editorFind.test.ts new file mode 100644 index 00000000..ec8db09f --- /dev/null +++ b/src/utils/editorFind.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { + buildEditorFindReplacementChange, + buildEditorFindReplacementChanges, + findEditorMatches, + nextEditorFindIndex, +} from './editorFind' + +describe('editorFind', () => { + it('finds plain-text matches case-insensitively by default', () => { + const result = findEditorMatches('Alpha beta alpha', 'alpha', { + caseSensitive: false, + regex: false, + }) + + expect(result.error).toBeNull() + expect(result.matches).toEqual([ + { from: 0, text: 'Alpha', to: 5 }, + { from: 11, text: 'alpha', to: 16 }, + ]) + }) + + it('supports case-sensitive matching', () => { + const result = findEditorMatches('Alpha beta alpha', 'alpha', { + caseSensitive: true, + regex: false, + }) + + expect(result.matches).toEqual([{ from: 11, text: 'alpha', to: 16 }]) + }) + + it('reports invalid or zero-width regex patterns', () => { + expect(findEditorMatches('body', '[', { caseSensitive: false, regex: true })).toMatchObject({ + error: 'Invalid regex', + matches: [], + }) + + expect(findEditorMatches('body', '^', { caseSensitive: false, regex: true })).toMatchObject({ + error: 'Regex must match text', + matches: [], + }) + }) + + it('builds regex replacement changes with capture groups', () => { + const result = findEditorMatches('foo-123 foo-456', 'foo-(\\d+)', { + caseSensitive: false, + regex: true, + }) + + expect(buildEditorFindReplacementChange(result.matches[0]!, 'foo-(\\d+)', 'bar-$1', { + caseSensitive: false, + regex: true, + })).toEqual({ from: 0, insert: 'bar-123', to: 7 }) + + expect(buildEditorFindReplacementChanges(result.matches, 'foo-(\\d+)', 'bar-$1', { + caseSensitive: false, + regex: true, + })).toEqual([ + { from: 0, insert: 'bar-123', to: 7 }, + { from: 8, insert: 'bar-456', to: 15 }, + ]) + }) + + it('wraps next and previous match navigation', () => { + expect(nextEditorFindIndex(0, 3, 1)).toBe(1) + expect(nextEditorFindIndex(2, 3, 1)).toBe(0) + expect(nextEditorFindIndex(0, 3, -1)).toBe(2) + }) +}) diff --git a/src/utils/editorFind.ts b/src/utils/editorFind.ts new file mode 100644 index 00000000..4aba81b5 --- /dev/null +++ b/src/utils/editorFind.ts @@ -0,0 +1,125 @@ +export interface EditorFindOptions { + caseSensitive: boolean + regex: boolean +} + +export interface EditorFindMatch { + from: number + text: string + to: number +} + +export interface EditorFindResult { + error: string | null + matches: EditorFindMatch[] +} + +export interface EditorFindChange { + from: number + insert: string + to: number +} + +type CompiledPattern = + | { error: string; pattern: null } + | { error: null; pattern: RegExp | null } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function compileFindPattern( + query: string, + options: EditorFindOptions, + global: boolean, +): CompiledPattern { + if (query.length === 0) return { error: null, pattern: null } + + const source = options.regex ? query : escapeRegExp(query) + const flags = `${global ? 'g' : ''}${options.caseSensitive ? '' : 'i'}` + + try { + return { error: null, pattern: new RegExp(source, flags) } + } catch { + return { error: 'Invalid regex', pattern: null } + } +} + +export function findEditorMatches( + documentText: string, + query: string, + options: EditorFindOptions, +): EditorFindResult { + const compiled = compileFindPattern(query, options, true) + if (compiled.error) return { error: compiled.error, matches: [] } + if (!compiled.pattern) return { error: null, matches: [] } + + const matches: EditorFindMatch[] = [] + let match: RegExpExecArray | null + + while ((match = compiled.pattern.exec(documentText)) !== null) { + if (match[0].length === 0) { + return { error: 'Regex must match text', matches: [] } + } + + matches.push({ + from: match.index, + text: match[0], + to: match.index + match[0].length, + }) + } + + return { error: null, matches } +} + +export function clampEditorFindIndex(index: number, matchCount: number): number { + if (matchCount <= 0) return 0 + return Math.min(Math.max(index, 0), matchCount - 1) +} + +export function nextEditorFindIndex( + index: number, + matchCount: number, + direction: 1 | -1, +): number { + if (matchCount <= 0) return 0 + return (clampEditorFindIndex(index, matchCount) + direction + matchCount) % matchCount +} + +export function replacementForEditorFindMatch( + match: EditorFindMatch, + query: string, + replacement: string, + options: EditorFindOptions, +): string { + if (!options.regex) return replacement + + const compiled = compileFindPattern(query, options, false) + if (!compiled.pattern) return replacement + + return match.text.replace(compiled.pattern, replacement) +} + +export function buildEditorFindReplacementChange( + match: EditorFindMatch, + query: string, + replacement: string, + options: EditorFindOptions, +): EditorFindChange { + return { + from: match.from, + insert: replacementForEditorFindMatch(match, query, replacement, options), + to: match.to, + } +} + +export function buildEditorFindReplacementChanges( + matches: readonly EditorFindMatch[], + query: string, + replacement: string, + options: EditorFindOptions, +): EditorFindChange[] { + return matches.map((match) => ( + buildEditorFindReplacementChange(match, query, replacement, options) + )) +} diff --git a/src/utils/editorFindEvents.ts b/src/utils/editorFindEvents.ts new file mode 100644 index 00000000..bbaa89c3 --- /dev/null +++ b/src/utils/editorFindEvents.ts @@ -0,0 +1,24 @@ +export const EDITOR_FIND_AVAILABILITY_EVENT = 'laputa:editor-find-availability' + +interface EditorFindAvailabilityDetail { + enabled: boolean +} + +function isAvailabilityDetail(detail: unknown): detail is EditorFindAvailabilityDetail { + return typeof detail === 'object' + && detail !== null + && 'enabled' in detail + && typeof (detail as { enabled?: unknown }).enabled === 'boolean' +} + +export function dispatchEditorFindAvailability(enabled: boolean): void { + window.dispatchEvent(new CustomEvent( + EDITOR_FIND_AVAILABILITY_EVENT, + { detail: { enabled } }, + )) +} + +export function readEditorFindAvailability(event: Event): boolean | null { + if (!(event instanceof CustomEvent) || !isAvailabilityDetail(event.detail)) return null + return event.detail.enabled +} diff --git a/tests/smoke/editor-find-replace.spec.ts b/tests/smoke/editor-find-replace.spec.ts new file mode 100644 index 00000000..73b2249e --- /dev/null +++ b/tests/smoke/editor-find-replace.spec.ts @@ -0,0 +1,62 @@ +import { expect, test, type Page } from '@playwright/test' +import { + createFixtureVaultCopy, + openFixtureVaultDesktopHarness, + removeFixtureVaultCopy, +} from '../helpers/fixtureVault' + +const FIND_SHORTCUT = process.platform === 'darwin' ? 'Meta+F' : 'Control+F' + +let tempVaultDir: string + +async function getRawEditorDoc(page: Page): Promise { + return page.evaluate(() => { + const host = document.querySelector('[data-testid="raw-editor-codemirror"]') as (HTMLElement & { + __cmView?: { state: { doc: { toString(): string } } } + }) | null + if (!host?.__cmView) { + throw new Error('Raw editor CodeMirror view is not mounted') + } + return host.__cmView.state.doc.toString() + }) +} + +test.describe('editor find and replace', () => { + test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVaultDesktopHarness(page, tempVaultDir) + await page.setViewportSize({ width: 1600, height: 900 }) + }) + + test.afterEach(() => { + removeFixtureVaultCopy(tempVaultDir) + }) + + test('Cmd+F opens current-note find and supports regex replacement @smoke', async ({ page }) => { + await page.getByText('Note B', { exact: true }).first().click() + await page.locator('.bn-editor').click() + + await page.keyboard.press(FIND_SHORTCUT) + + await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 }) + await expect(page.getByTestId('raw-editor-find-input')).toBeFocused() + + await page.getByRole('button', { name: 'Show replace' }).click() + await page.getByRole('button', { name: 'Use regular expression' }).click() + const findInput = page.getByTestId('raw-editor-find-input') + await findInput.pressSequentially('Note ([BC])') + await expect(findInput).toBeFocused() + await expect(findInput).toHaveValue('Note ([BC])') + await expect(page.getByTestId('raw-editor-find-count')).toContainText('1 / 3') + + await page.getByTestId('raw-editor-replace-input').fill('Entry $1') + await page.getByRole('button', { name: 'Replace', exact: true }).click() + + await expect.poll(() => getRawEditorDoc(page)).toContain('# Entry B') + + await page.keyboard.press('Escape') + await expect(page.getByTestId('raw-editor-find-bar')).toHaveCount(0) + await expect(page.locator('.cm-content')).toBeFocused() + }) +})