From b7efaabcdc5ff0e8b4a37733475fd5043e3b1a5c Mon Sep 17 00:00:00 2001 From: rechard-guo Date: Tue, 7 Jul 2026 11:29:38 +0800 Subject: [PATCH] Feature(Desktop): #974 Add ctrl+F keyboard shortcut to open find-in-page --- desktop/src/components/layout/Sidebar.tsx | 2 + .../src/components/search/FindInPageModal.tsx | 203 ++++++++++++++++++ desktop/src/hooks/useKeyboardShortcuts.ts | 6 + 3 files changed, 211 insertions(+) create mode 100644 desktop/src/components/search/FindInPageModal.tsx diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9a692dd5..7a8cfce1 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -5,6 +5,7 @@ import { useUIStore } from '../../stores/uiStore' import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import { GlobalSearchModal } from '../search/GlobalSearchModal' +import { FindInPageModal } from '../search/FindInPageModal' import type { SessionListItem } from '../../types/session' import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' @@ -1208,6 +1209,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { /> + ) } diff --git a/desktop/src/components/search/FindInPageModal.tsx b/desktop/src/components/search/FindInPageModal.tsx new file mode 100644 index 00000000..f38b1718 --- /dev/null +++ b/desktop/src/components/search/FindInPageModal.tsx @@ -0,0 +1,203 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { ChevronDown, ChevronUp, Search, X } from 'lucide-react' + +// JS-based scoped find-in-page. We walk text nodes in the document body EXCLUDING the +// sidebar (.sidebar-panel) and this find bar ([data-find-bar]), then highlight matches +// with the CSS Custom Highlight API (Range-based, no DOM mutation → React-safe). +// Why not native webContents.findInPage: it scans the whole document incl. the +// value, so the search box matches itself and steals focus/caret. Scoping sidesteps that. + +const FIND_DEBOUNCE_MS = 250 +const RESULTS_HL = 'cc-find-results' +const ACTIVE_HL = 'cc-find-active' +// Subtrees never searched: sidebar, tab bar, this find bar, non-content tags. +const SKIP_CLOSEST = '.sidebar-panel, [data-testid="tab-bar"], [data-find-bar], script, style, noscript, .material-symbols-outlined' + +type Props = { + open: boolean + onClose: () => void +} + +export function FindInPageModal({ open, onClose }: Props) { + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [count, setCount] = useState(0) + const [activeIndex, setActiveIndex] = useState(0) + const inputRef = useRef(null) + const rangesRef = useRef([]) + + // Focus + reset whenever the bar opens; clear highlights when it closes. + useEffect(() => { + if (!open) { + clearHighlights() + return + } + setQuery('') + setDebouncedQuery('') + setCount(0) + setActiveIndex(0) + rangesRef.current = [] + const id = requestAnimationFrame(() => inputRef.current?.focus()) + return () => cancelAnimationFrame(id) + }, [open]) + + // Debounce the typed query. + useEffect(() => { + const id = setTimeout(() => setDebouncedQuery(query), FIND_DEBOUNCE_MS) + return () => clearTimeout(id) + }, [query]) + + // Clear highlights on unmount. + useEffect(() => () => clearHighlights(), []) + + // Run (or clear) the search once the query settles. + useEffect(() => { + const q = debouncedQuery.trim() + if (!q) { + clearHighlights() + rangesRef.current = [] + setCount(0) + setActiveIndex(0) + return + } + const ranges = collectRanges(q) + rangesRef.current = ranges + setCount(ranges.length) + setActiveIndex(0) + paint(ranges, 0) + }, [debouncedQuery]) + + // Next/previous — immediate, uses live state. + function step(forward: boolean) { + const ranges = rangesRef.current + if (ranges.length === 0) return + const next = forward ? (activeIndex + 1) % ranges.length : (activeIndex - 1 + ranges.length) % ranges.length + setActiveIndex(next) + paint(ranges, next) + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + step(!e.shiftKey) // Enter = next, Shift+Enter = previous + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + + if (!open) return null + + return createPortal( +
+ +
+
+
, + document.body, + ) +} + +// ---- search core (module scope, no React state) ---- + +/** Walk visible text nodes outside skipped subtrees; return a Range per case-insensitive match. */ +function collectRanges(q: string): Range[] { + const ranges: Range[] = [] + const needle = q.toLowerCase() + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement + if (!parent || !node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT + if (parent.closest(SKIP_CLOSEST)) return NodeFilter.FILTER_REJECT + return NodeFilter.FILTER_ACCEPT + }, + }) + let textNode = walker.nextNode() as Text | null + while (textNode) { + const text = textNode.nodeValue!.toLowerCase() + let idx = text.indexOf(needle) + while (idx !== -1) { + const range = document.createRange() + range.setStart(textNode, idx) + range.setEnd(textNode, idx + needle.length) + ranges.push(range) + idx = text.indexOf(needle, idx + needle.length) + } + textNode = walker.nextNode() as Text | null + } + return ranges +} + +/** Register CSS highlights for all matches + the active one, and scroll the active into view. */ +function paint(ranges: Range[], activeIndex: number) { + const highlights = (CSS as any).highlights as Map | undefined + const HighlightCtor = (globalThis as any).Highlight + if (highlights && HighlightCtor) { + const results = new HighlightCtor() + for (const r of ranges) results.add(r) + highlights.set(RESULTS_HL, results) + const active = ranges[activeIndex] + if (active) { + const activeHl = new HighlightCtor() + activeHl.add(active) + activeHl.priority = 1 // paint over the results highlight + highlights.set(ACTIVE_HL, activeHl) + } else { + highlights.delete(ACTIVE_HL) + } + } + ranges[activeIndex]?.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' }) +} + +function clearHighlights() { + const highlights = (CSS as any).highlights as Map | undefined + highlights?.delete(RESULTS_HL) + highlights?.delete(ACTIVE_HL) +} diff --git a/desktop/src/hooks/useKeyboardShortcuts.ts b/desktop/src/hooks/useKeyboardShortcuts.ts index 8a299e78..5382c3bc 100644 --- a/desktop/src/hooks/useKeyboardShortcuts.ts +++ b/desktop/src/hooks/useKeyboardShortcuts.ts @@ -56,6 +56,12 @@ export function useKeyboardShortcuts() { openModal('globalSearch') } + // Ctrl+F — Open find-in-page bar + if (meta && e.key === 'f') { + e.preventDefault() + openModal('findInPage') + } + // Escape — Close modal or clear state if (e.key === 'Escape') { if (activeModalRef.current) {