From dd8ec1afec44367af7c789357c2ee25e21b42607 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 19 Jan 2026 17:32:57 +0800 Subject: [PATCH 1/2] feat: add page search functionality (Ctrl+F / Cmd+F) - Add SearchBar component with floating UI at top-right corner - Implement JavaScript-based text search for both Electron and Tauri - Support match case toggle and navigation between matches - Add keyboard shortcut Cmd+F (macOS) / Ctrl+F (Windows/Linux) - Use TreeWalker + Range API for consistent cross-platform behavior - Add search-related translations for all supported languages Co-Authored-By: Claude Opus 4.5 --- shared/keymap.ts | 1 + src/App.tsx | 2 + src/atoms/hotkeyState.ts | 5 + src/atoms/searchState.ts | 65 +++++++ src/components/SearchBar.tsx | 244 ++++++++++++++++++++++++ src/ipc/search.ts | 257 ++++++++++++++++++++++++++ src/locales/de/translation.json | 10 +- src/locales/en/translation.json | 8 + src/locales/es/translation.json | 10 +- src/locales/fi/translation.json | 10 +- src/locales/fil/translation.json | 10 +- src/locales/fr/translation.json | 10 +- src/locales/id/translation.json | 10 +- src/locales/it/translation.json | 10 +- src/locales/ja/translation.json | 10 +- src/locales/ko/translation.json | 10 +- src/locales/lo/translation.json | 10 +- src/locales/no/translation.json | 10 +- src/locales/pl/translation.json | 10 +- src/locales/pt/translation.json | 10 +- src/locales/ru/translation.json | 10 +- src/locales/sv/translation.json | 10 +- src/locales/th/translation.json | 10 +- src/locales/tr/translation.json | 10 +- src/locales/uk/translation.json | 10 +- src/locales/vi/translation.json | 10 +- src/locales/zh-CN/translation.json | 10 +- src/locales/zh-TW/translation.json | 10 +- src/styles/components/_SearchBar.scss | 138 ++++++++++++++ src/styles/index.scss | 1 + 30 files changed, 910 insertions(+), 21 deletions(-) create mode 100644 src/atoms/searchState.ts create mode 100644 src/components/SearchBar.tsx create mode 100644 src/ipc/search.ts create mode 100644 src/styles/components/_SearchBar.scss diff --git a/shared/keymap.ts b/shared/keymap.ts index ef90ce77..4b54c1f2 100644 --- a/shared/keymap.ts +++ b/shared/keymap.ts @@ -15,6 +15,7 @@ export function getKeymap() { "global:rename-chat": `<${mod}-t>`, "global:setting-page": `<${mod}-,>`, "global:close-window": `<${mod}-w>`, + "global:toggle-search": `<${mod}-f>`, // "global:reload": `<${mod}-r>`, } } diff --git a/src/App.tsx b/src/App.tsx index e65ccb97..f0474012 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { refreshConfig } from "./ipc/host" import { openOverlayAtom } from "./atoms/layerState" import PopupConfirm from "./components/PopupConfirm" import PopupElicitationRequest from "./components/PopupElicitationRequest" +import SearchBar from "./components/SearchBar" import { elicitationRequestsAtom, addElicitationRequestAtom, removeElicitationRequestAtom, type ElicitationAction, type ElicitationContent } from "./atoms/chatState" import { responseLocalIPCElicitation } from "./ipc" import camelcaseKeys from "camelcase-keys" @@ -244,6 +245,7 @@ function App() { <> + {installToolConfirm && (null) + +// Whether initial search is ready for navigation +export const searchReadyAtom = atom(false) + +// Reset search ready state when text changes +export const setSearchTextAtom = atom( + (get) => get(searchTextAtom), + (get, set, newText: string) => { + set(searchTextAtom, newText) + set(searchReadyAtom, false) + } +) + +// Toggle search visibility +export const toggleSearchAtom = atom( + (get) => get(searchVisibleAtom), + (get, set) => { + const isVisible = get(searchVisibleAtom) + set(searchVisibleAtom, !isVisible) + if (isVisible) { + // When closing, clear search state + set(searchTextAtom, "") + set(searchResultAtom, null) + set(searchReadyAtom, false) + } + } +) + +// Open search +export const openSearchAtom = atom( + null, + (get, set) => { + set(searchVisibleAtom, true) + } +) + +// Close search +export const closeSearchAtom = atom( + null, + (get, set) => { + set(searchVisibleAtom, false) + set(searchTextAtom, "") + set(searchResultAtom, null) + set(searchReadyAtom, false) + } +) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 00000000..80661f53 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,244 @@ +import { useEffect, useRef, useCallback } from "react" +import { useAtom, useSetAtom, useAtomValue } from "jotai" +import { useTranslation } from "react-i18next" +import { + searchVisibleAtom, + searchTextAtom, + searchMatchCaseAtom, + searchResultAtom, + searchReadyAtom, + closeSearchAtom, +} from "../atoms/searchState" +import { + findInPage, + findNext, + findPrev, + stopFind, + listenSearchResult, +} from "../ipc/search" + +export default function SearchBar() { + const { t } = useTranslation() + const inputRef = useRef(null) + const isVisible = useAtomValue(searchVisibleAtom) + const [searchText, setSearchText] = useAtom(searchTextAtom) + const [matchCase, setMatchCase] = useAtom(searchMatchCaseAtom) + const [searchResult, setSearchResult] = useAtom(searchResultAtom) + const [searchReady, setSearchReady] = useAtom(searchReadyAtom) + const closeSearch = useSetAtom(closeSearchAtom) + + // Focus input when search bar becomes visible + useEffect(() => { + if (isVisible && inputRef.current) { + inputRef.current.focus() + inputRef.current.select() + } + }, [isVisible]) + + // Listen for search results + useEffect(() => { + const unsubscribe = listenSearchResult((result) => { + setSearchResult({ + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + finalUpdate: result.finalUpdate, + }) + // Mark search as ready when we receive the final update + if (result.finalUpdate) { + setSearchReady(true) + } + }) + + return () => { + unsubscribe() + } + }, [setSearchResult, setSearchReady]) + + // Perform search when text or matchCase changes + useEffect(() => { + if (!isVisible) { + return + } + + // Reset ready state when starting a new search + setSearchReady(false) + + const debounceTimer = setTimeout(() => { + if (searchText) { + findInPage(searchText, { matchCase }) + } else { + stopFind() + setSearchResult(null) + setSearchReady(false) + } + }, 150) + + return () => clearTimeout(debounceTimer) + }, [searchText, matchCase, isVisible, setSearchResult, setSearchReady]) + + // Clean up when closing + useEffect(() => { + if (!isVisible) { + stopFind() + } + }, [isVisible]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + if (!searchText) { + return + } + // If search is not ready or no results, perform initial search + if (!searchReady || !searchResult || searchResult.matches === 0) { + findInPage(searchText, { matchCase }) + return + } + if (e.shiftKey) { + findPrev(searchText, { matchCase }) + } else { + findNext(searchText, { matchCase }) + } + } else if (e.key === "Escape") { + e.preventDefault() + closeSearch() + } + }, [searchText, matchCase, closeSearch, searchResult, searchReady]) + + const handlePrevClick = useCallback(() => { + findPrev(searchText, { matchCase }) + }, [searchText, matchCase]) + + const handleNextClick = useCallback(() => { + findNext(searchText, { matchCase }) + }, [searchText, matchCase]) + + const handleClose = useCallback(() => { + closeSearch() + }, [closeSearch]) + + const toggleMatchCase = useCallback(() => { + setMatchCase(!matchCase) + }, [matchCase, setMatchCase]) + + const handleTextChange = useCallback((e: React.ChangeEvent) => { + setSearchText(e.target.value) + setSearchReady(false) + }, [setSearchText, setSearchReady]) + + if (!isVisible) { + return null + } + + const hasMatches = searchResult && searchResult.matches > 0 + const matchCountText = searchResult + ? `${searchResult.activeMatchOrdinal} / ${searchResult.matches}` + : "" + + return ( +
+
+ + + + + + {searchText && ( + + {searchText && !searchResult ? "..." : matchCountText} + + )} +
+ + + +
+ + + + + +
+ + +
+ ) +} diff --git a/src/ipc/search.ts b/src/ipc/search.ts new file mode 100644 index 00000000..4b20afeb --- /dev/null +++ b/src/ipc/search.ts @@ -0,0 +1,257 @@ +export interface SearchResult { + activeMatchOrdinal: number + matches: number + finalUpdate?: boolean +} + +export interface SearchOptions { + matchCase?: boolean +} + +// JavaScript-based text search using TreeWalker + Range API + mark elements +// Works for both Electron and Tauri for consistent behavior +class TextSearch { + private currentIndex = 0 + private matches: Range[] = [] + private highlightClass = "dive-search-highlight" + private activeClass = "dive-search-highlight-active" + private currentSearchText = "" + private currentMatchCase = false + private resultCallback: ((result: SearchResult) => void) | null = null + + constructor() { + // Create styles for highlights + this.injectStyles() + } + + private injectStyles() { + if (document.getElementById("dive-search-styles")) { + return + } + + const style = document.createElement("style") + style.id = "dive-search-styles" + style.textContent = ` + .${this.highlightClass} { + background-color: rgba(255, 235, 59, 0.5); + border-radius: 2px; + } + .${this.activeClass} { + background-color: rgba(255, 152, 0, 0.7); + border-radius: 2px; + } + ` + document.head.appendChild(style) + } + + private clearHighlights() { + const highlights = document.querySelectorAll(`.${this.highlightClass}`) + highlights.forEach((el) => { + const parent = el.parentNode + if (parent) { + const text = document.createTextNode(el.textContent || "") + parent.replaceChild(text, el) + parent.normalize() + } + }) + this.matches = [] + this.currentIndex = 0 + } + + private findTextNodes(root: Node): Text[] { + const textNodes: Text[] = [] + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => { + // Skip empty text nodes and hidden elements + if (!node.textContent?.trim()) { + return NodeFilter.FILTER_REJECT + } + const parent = node.parentElement + if (!parent) { + return NodeFilter.FILTER_REJECT + } + // Skip script/style/hidden elements + const tagName = parent.tagName.toLowerCase() + if (tagName === "script" || tagName === "style" || tagName === "noscript") { + return NodeFilter.FILTER_REJECT + } + // Skip the search bar itself + if (parent.closest(".search-bar")) { + return NodeFilter.FILTER_REJECT + } + // Skip already highlighted text (to avoid double-highlighting) + if (parent.classList.contains(this.highlightClass)) { + return NodeFilter.FILTER_REJECT + } + const style = window.getComputedStyle(parent) + if (style.display === "none" || style.visibility === "hidden") { + return NodeFilter.FILTER_REJECT + } + return NodeFilter.FILTER_ACCEPT + }, + } + ) + + let node + while ((node = walker.nextNode())) { + textNodes.push(node as Text) + } + return textNodes + } + + private highlightMatches(text: string, matchCase: boolean) { + this.clearHighlights() + if (!text) { + return + } + + const textNodes = this.findTextNodes(document.body) + const searchText = matchCase ? text : text.toLowerCase() + + textNodes.forEach((textNode) => { + const content = textNode.textContent || "" + const compareContent = matchCase ? content : content.toLowerCase() + let startIndex = 0 + let index: number + + while ((index = compareContent.indexOf(searchText, startIndex)) !== -1) { + const range = document.createRange() + range.setStart(textNode, index) + range.setEnd(textNode, index + searchText.length) + + const mark = document.createElement("mark") + mark.className = this.highlightClass + + try { + range.surroundContents(mark) + this.matches.push(range) + // After surrounding, the textNode has been split, so we need to continue from the new position + startIndex = 0 + break // Break to re-find in new text nodes + } catch (_e) { + // Range spans multiple elements, skip + startIndex = index + 1 + } + } + }) + + // If we have matches, highlight the first one as active + if (this.matches.length > 0) { + this.currentIndex = 0 + this.updateActiveHighlight() + } + + this.notifyResult() + } + + private updateActiveHighlight() { + // Remove active class from all highlights + document.querySelectorAll(`.${this.activeClass}`).forEach((el) => { + el.classList.remove(this.activeClass) + }) + + // Add active class to current match + const highlights = document.querySelectorAll(`.${this.highlightClass}`) + if (highlights[this.currentIndex]) { + highlights[this.currentIndex].classList.add(this.activeClass) + highlights[this.currentIndex].scrollIntoView({ + behavior: "smooth", + block: "center", + }) + } + } + + private notifyResult() { + const highlights = document.querySelectorAll(`.${this.highlightClass}`) + if (this.resultCallback) { + this.resultCallback({ + activeMatchOrdinal: highlights.length > 0 ? this.currentIndex + 1 : 0, + matches: highlights.length, + finalUpdate: true, + }) + } + } + + find(text: string, options?: SearchOptions): void { + this.currentSearchText = text + this.currentMatchCase = options?.matchCase ?? false + this.highlightMatches(text, this.currentMatchCase) + } + + findNext(): void { + const highlights = document.querySelectorAll(`.${this.highlightClass}`) + if (highlights.length === 0) { + return + } + + this.currentIndex = (this.currentIndex + 1) % highlights.length + this.updateActiveHighlight() + this.notifyResult() + } + + findPrev(): void { + const highlights = document.querySelectorAll(`.${this.highlightClass}`) + if (highlights.length === 0) { + return + } + + this.currentIndex = (this.currentIndex - 1 + highlights.length) % highlights.length + this.updateActiveHighlight() + this.notifyResult() + } + + stop(): void { + this.clearHighlights() + this.currentSearchText = "" + this.currentMatchCase = false + if (this.resultCallback) { + this.resultCallback({ + activeMatchOrdinal: 0, + matches: 0, + finalUpdate: true, + }) + } + } + + onResult(callback: (result: SearchResult) => void): void { + this.resultCallback = callback + } +} + +// Singleton instance for search (works for both Electron and Tauri) +let searchInstance: TextSearch | null = null + +function getSearch(): TextSearch { + if (!searchInstance) { + searchInstance = new TextSearch() + } + return searchInstance +} + +// Unified search API - use JS-based search for both Electron and Tauri +// This ensures consistent behavior and proper scrolling in nested containers +export async function findInPage(text: string, options?: SearchOptions): Promise { + getSearch().find(text, options) +} + +export async function findNext(_text: string, _options?: SearchOptions): Promise { + getSearch().findNext() +} + +export async function findPrev(_text: string, _options?: SearchOptions): Promise { + getSearch().findPrev() +} + +export async function stopFind(): Promise { + getSearch().stop() +} + +export function listenSearchResult(callback: (result: SearchResult) => void): () => void { + getSearch().onResult(callback) + return () => { + // No cleanup needed + } +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 8a5a7524..533a2778 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Chat umbenennen", "global_setting-page": "Verwaltung und Einstellungen öffnen", "global_close-window": "Beenden/Minimieren", - "global_reload": "Fenster neu laden" + "global_reload": "Fenster neu laden", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "PauseLeertasteoderK", "exitFullscreen": "Vollbild beendenFoderEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7326454a..41fe3448 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -533,6 +533,7 @@ "global_rename-chat": "Rename Chat", "global_setting-page": "Open Management and Settings", "global_close-window": "Quit/Minimize Window", + "global_toggle-search": "Find in Page", "global_reload": "Reload Window" } }, @@ -577,5 +578,12 @@ "pause": "Pause Space or k", "exitFullscreen": "Exit Fullscreen F or Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 62d81a8d..2d4329da 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -533,7 +533,8 @@ "global_rename-chat": "Renombrar Chat", "global_setting-page": "Abrir Gestión y Configuración", "global_close-window": "Salir/Minimizar Ventana", - "global_reload": "Recargar Ventana" + "global_reload": "Recargar Ventana", + "global_toggle-search": "Find in Page" } }, "update": { @@ -577,5 +578,12 @@ "pause": "PausarEspaciooK", "exitFullscreen": "Salir de pantalla completaFoEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index 8fbd4693..ff39ea1d 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Nimeä keskustelu uudelleen", "global_setting-page": "Avaa hallinta ja asetukset", "global_close-window": "Lopeta/Pienennä ikkuna", - "global_reload": "Lataa ikkuna uudelleen" + "global_reload": "Lataa ikkuna uudelleen", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "Tauko Välilyönti tai k", "exitFullscreen": "Poistu koko näytöstä F tai Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/fil/translation.json b/src/locales/fil/translation.json index d60d6a03..c892db0f 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Palitan ang pangalan ng Chat", "global_setting-page": "Buksan ang Pamamahala at Mga Setting", "global_close-window": "Lumabas/I-minimize ang Window", - "global_reload": "I-reload ang Window" + "global_reload": "I-reload ang Window", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "I-pause Space o k", "exitFullscreen": "Lumabas sa fullscreen F o Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 4c9a74fb..2ac58746 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Renommer la discussion", "global_setting-page": "Ouvrir gestion et paramètres", "global_close-window": "Quitter/Réduire la fenêtre", - "global_reload": "Recharger la fenêtre" + "global_reload": "Recharger la fenêtre", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "PauseEspaceouK", "exitFullscreen": "Quitter le plein écranFouEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 644c8e10..c2d88765 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Ubah Nama Obrolan", "global_setting-page": "Buka manajemen dan pengaturan", "global_close-window": "Keluar/Minimalkan Jendela", - "global_reload": "Muat Ulang Jendela" + "global_reload": "Muat Ulang Jendela", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "Jeda Spasi atau k", "exitFullscreen": "Keluar dari Layar Penuh F atau Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index e771fc64..258ed0b8 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Rinomina chat", "global_setting-page": "Apri gestione e impostazioni", "global_close-window": "Esci/Riduci finestra", - "global_reload": "Ricarica finestra" + "global_reload": "Ricarica finestra", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "PausaSpaziooK", "exitFullscreen": "Esci da schermo interoFoEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index d59758cc..4adfd425 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -534,7 +534,8 @@ "global_rename-chat": "チャット名を変更", "global_setting-page": "管理と設定を開く", "global_close-window": "終了/最小化", - "global_reload": "ウィンドウを再読み込み" + "global_reload": "ウィンドウを再読み込み", + "global_toggle-search": "Find in Page" } }, "update": { @@ -578,5 +579,12 @@ "pause": "一時停止スペースまたはK", "exitFullscreen": "全画面表示を終了FまたはEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 6c9b4f3b..eb5926b0 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -533,7 +533,8 @@ "global_rename-chat": "채팅 이름 변경", "global_setting-page": "관리 및 설정 열기", "global_close-window": "종료/최소화", - "global_reload": "창 새로고침" + "global_reload": "창 새로고침", + "global_toggle-search": "Find in Page" } }, "update": { @@ -577,5 +578,12 @@ "pause": "일시정지스페이스또는K", "exitFullscreen": "전체 화면 종료F또는Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/lo/translation.json b/src/locales/lo/translation.json index a1153299..d97c8d0c 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "ປ່ຽນຊື່ການສົນທະນາ", "global_setting-page": "ເປີດການຈັດການແລະການຕັ້ງຄ່າ", "global_close-window": "ອອກ/ຫຍໍ້ໜ້າຕ່າງ", - "global_reload": "ໂຫຼດໜ້າຕ່າງຄືນໃໝ່" + "global_reload": "ໂຫຼດໜ້າຕ່າງຄືນໃໝ່", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "ຢຸດຊົ່ວຄາວ Space ຫຼື k", "exitFullscreen": "ອອກຈາກເຕັມຈໍ F ຫຼື Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/no/translation.json b/src/locales/no/translation.json index a88b5e0a..af3e7fb0 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Gi nytt navn til chat", "global_setting-page": "Åpne administrasjon og innstillinger", "global_close-window": "Avslutt/Minimer vindu", - "global_reload": "Last vindu på nytt" + "global_reload": "Last vindu på nytt", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "Pause Mellomrom eller k", "exitFullscreen": "Avslutt fullskjerm F eller Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index a1c62c05..2ade776a 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Zmień nazwę czatu", "global_setting-page": "Otwórz zarządzanie i ustawienia", "global_close-window": "Zakończ/Minimalizuj okno", - "global_reload": "Przeładuj okno" + "global_reload": "Przeładuj okno", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "Pauza Spacja lub k", "exitFullscreen": "Wyjdź z pełnego ekranu F lub Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 245fbfbb..32a1adc5 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Renomear Conversa", "global_setting-page": "Abrir gerenciamento e configurações", "global_close-window": "Sair/Minimizar Janela", - "global_reload": "Recarregar Janela" + "global_reload": "Recarregar Janela", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "PausarEspaçoouK", "exitFullscreen": "Sair da tela cheiaFouEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 3d0b0aae..32b0896a 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Переименовать чат", "global_setting-page": "Открыть управление и настройки", "global_close-window": "Выход/Свернуть окно", - "global_reload": "Перезагрузить окно" + "global_reload": "Перезагрузить окно", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "ПаузаПробелилиK", "exitFullscreen": "Выйти из полноэкранного режимаFилиEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index 07c8dfdb..2afc3cfa 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "Byt namn på chatt", "global_setting-page": "Öppna hantering och inställningar", "global_close-window": "Avsluta/Minimera fönster", - "global_reload": "Ladda om fönster" + "global_reload": "Ladda om fönster", + "global_toggle-search": "Find in Page" } }, "update": { @@ -559,5 +560,12 @@ "pause": "Pausa Mellanslag eller k", "exitFullscreen": "Avsluta helskärm F eller Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/th/translation.json b/src/locales/th/translation.json index d5309830..81f1db1a 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -515,7 +515,8 @@ "global_rename-chat": "เปลี่ยนชื่อแชท", "global_setting-page": "เปิดการจัดการและการตั้งค่า", "global_close-window": "ออก/ย่อหน้าต่าง", - "global_reload": "โหลดหน้าต่างใหม่" + "global_reload": "โหลดหน้าต่างใหม่", + "global_toggle-search": "Find in Page" } }, "update": { @@ -555,5 +556,12 @@ "pause": "หยุดชั่วคราวSpaceหรือK", "exitFullscreen": "ออกจากเต็มหน้าจอFหรือEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 97424ed2..bf767199 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -513,7 +513,8 @@ "global_rename-chat": "Sohbeti Yeniden Adlandır", "global_setting-page": "Yönetim ve ayarları aç", "global_close-window": "Çıkış/Küçült", - "global_reload": "Pencereyi Yeniden Yükle" + "global_reload": "Pencereyi Yeniden Yükle", + "global_toggle-search": "Find in Page" } }, "update": { @@ -557,5 +558,12 @@ "pause": "Duraklat Boşluk veya k", "exitFullscreen": "Tam Ekrandan Çık F veya Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 1c825962..fcec95f2 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -513,7 +513,8 @@ "global_rename-chat": "Перейменувати чат", "global_setting-page": "Відкрити управління та налаштування", "global_close-window": "Вихід/Згорнути вікно", - "global_reload": "Перезавантажити вікно" + "global_reload": "Перезавантажити вікно", + "global_toggle-search": "Find in Page" } }, "update": { @@ -557,5 +558,12 @@ "pause": "Пауза Пробіл або k", "exitFullscreen": "Вийти з повноекранного режиму F або Esc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json index d8d3f9c6..6e7db30e 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -513,7 +513,8 @@ "global_rename-chat": "Đổi tên trò chuyện", "global_setting-page": "Mở quản lý và cài đặt", "global_close-window": "Thoát/Thu nhỏ cửa sổ", - "global_reload": "Tải lại cửa sổ" + "global_reload": "Tải lại cửa sổ", + "global_toggle-search": "Find in Page" } }, "update": { @@ -553,5 +554,12 @@ "pause": "Tạm dừngPhím cáchhoặcK", "exitFullscreen": "Thoát toàn màn hìnhFhoặcEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index cb94cd74..28f79b71 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -533,7 +533,8 @@ "global_rename-chat": "重新命名对话", "global_setting-page": "打开管理与设置", "global_close-window": "退出/最小化窗口", - "global_reload": "重新加载窗口" + "global_reload": "重新加载窗口", + "global_toggle-search": "Find in Page" } }, "update": { @@ -577,5 +578,12 @@ "pause": "暂停空格键k", "exitFullscreen": "退出全屏FEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 719e2611..789279ff 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -533,7 +533,8 @@ "global_rename-chat": "重新命名對話", "global_setting-page": "開啟管理與設定", "global_close-window": "退出/最小化視窗", - "global_reload": "重新整理視窗" + "global_reload": "重新整理視窗", + "global_toggle-search": "Find in Page" } }, "update": { @@ -577,5 +578,12 @@ "pause": "暫停空格鍵k", "exitFullscreen": "退出全螢幕FEsc" } + }, + "search": { + "placeholder": "Find in page...", + "matchCase": "Match Case", + "previous": "Previous Match", + "next": "Next Match", + "noResults": "No results" } } \ No newline at end of file diff --git a/src/styles/components/_SearchBar.scss b/src/styles/components/_SearchBar.scss new file mode 100644 index 00000000..2774a8b0 --- /dev/null +++ b/src/styles/components/_SearchBar.scss @@ -0,0 +1,138 @@ +@use "../variables" as *; + +.search-bar { + position: fixed; + top: $header-height + 8px; + right: 16px; + z-index: $z-modal; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--bg-gray-weak); + border: 1px solid var(--stroke-gray-weak); + border-radius: 8px; + box-shadow: 0 4px 12px var(--sd); + backdrop-filter: blur(8px); + + .search-bar-input-wrapper { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg); + border: 1px solid var(--stroke-gray-weak); + border-radius: 6px; + padding: 4px 8px; + min-width: 200px; + + &:focus-within { + border-color: var(--stroke-pri-strong); + box-shadow: 0 0 0 2px var(--sd-pri); + } + } + + .search-bar-icon { + color: var(--text-weak); + flex-shrink: 0; + } + + .search-bar-input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--text-strong); + font-size: 14px; + min-width: 120px; + + &::placeholder { + color: var(--text-weak); + } + } + + .search-bar-count { + font-size: 12px; + color: var(--text-weak); + white-space: nowrap; + padding: 0 4px; + + &.no-results { + color: var(--text-danger); + } + } + + .search-bar-divider { + width: 1px; + height: 20px; + background: var(--stroke-gray-weak); + margin: 0 4px; + } + + .search-bar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-medium); + cursor: pointer; + transition: background $transition-fast, color $transition-fast; + + &:hover:not(:disabled) { + background: var(--bg-gray-medium); + color: var(--text-strong); + } + + &:active:not(:disabled) { + background: var(--bg-gray-strong); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + &.match-case { + font-size: 12px; + font-weight: 600; + width: auto; + padding: 0 8px; + + &.active { + background: var(--bg-pri-weak); + color: var(--text-pri); + } + } + + &.close { + &:hover:not(:disabled) { + background: var(--bg-danger-weak); + color: var(--text-danger); + } + } + + svg { + width: 16px; + height: 16px; + } + } +} + +// Animation for search bar appearance +@keyframes search-bar-slide-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.search-bar { + animation: search-bar-slide-in 0.15s ease-out; +} diff --git a/src/styles/index.scss b/src/styles/index.scss index a5157920..637504f3 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -14,6 +14,7 @@ @use "./components/Select"; @use "./components/DropDown"; @use "./components/Keymap"; +@use "./components/SearchBar"; @use "./overlay/Overlay"; * { From 0620677042c40c1248c931d23e471362939a543a Mon Sep 17 00:00:00 2001 From: john Date: Thu, 22 Jan 2026 16:34:54 +0800 Subject: [PATCH 2/2] fix: search bar icon size --- src/styles/components/_SearchBar.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/styles/components/_SearchBar.scss b/src/styles/components/_SearchBar.scss index 2774a8b0..038cd025 100644 --- a/src/styles/components/_SearchBar.scss +++ b/src/styles/components/_SearchBar.scss @@ -74,10 +74,11 @@ justify-content: center; width: 28px; height: 28px; + padding: 0; border: none; border-radius: 4px; background: transparent; - color: var(--text-medium); + color: var(--text-weak); cursor: pointer; transition: background $transition-fast, color $transition-fast;