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": "退出全屏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/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": "退出全螢幕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/styles/components/_SearchBar.scss b/src/styles/components/_SearchBar.scss
new file mode 100644
index 00000000..038cd025
--- /dev/null
+++ b/src/styles/components/_SearchBar.scss
@@ -0,0 +1,139 @@
+@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;
+ padding: 0;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-weak);
+ 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";
* {