diff options
| author | haoyuren <13851610112@163.com> | 2026-03-15 13:41:14 -0500 |
|---|---|---|
| committer | haoyuren <13851610112@163.com> | 2026-03-15 13:41:14 -0500 |
| commit | 3a1ad20d63f7d96dd6b4aee92b2851b3a35a8d92 (patch) | |
| tree | 1cbcbb9f1af46384caaa1450979e098a3a29c76d /src | |
| parent | 0b2431faad5271e4721fcf7f96917b1a314120b3 (diff) | |
v0.3.2: Add search features and SyncTeX forward searchv0.3.2
- In-file search: Cmd+F opens CodeMirror search panel with themed styling
- Multi-file search: Cmd+Shift+F or toolbar button opens project-wide search
- PDF text search: Cmd+F on PDF or search button to find text in PDF
- SyncTeX forward search: Cmd+Enter jumps from editor cursor to PDF position
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/main/index.ts | 104 | ||||
| -rw-r--r-- | src/preload/index.ts | 6 | ||||
| -rw-r--r-- | src/renderer/src/App.css | 196 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 43 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 3 | ||||
| -rw-r--r-- | src/renderer/src/components/PdfViewer.tsx | 207 | ||||
| -rw-r--r-- | src/renderer/src/components/SearchPanel.tsx | 132 | ||||
| -rw-r--r-- | src/renderer/src/components/Toolbar.tsx | 4 | ||||
| -rw-r--r-- | src/renderer/src/ot/overleafSync.ts | 4 | ||||
| -rw-r--r-- | src/renderer/src/stores/appStore.ts | 13 |
10 files changed, 704 insertions, 8 deletions
diff --git a/src/main/index.ts b/src/main/index.ts index 334691d..b6648bb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,8 +2,8 @@ // Licensed under AGPL-3.0 - see LICENSE file import { app, BrowserWindow, ipcMain, dialog, shell, net } from 'electron' -import { join, basename } from 'path' -import { readFile, writeFile, mkdir as mkdirAsync, unlink } from 'fs/promises' +import { join, basename, relative, extname } from 'path' +import { readFile, writeFile, mkdir as mkdirAsync, unlink, readdir, stat } from 'fs/promises' import { spawn } from 'child_process' import * as pty from 'node-pty' import { OverleafSocket, type RootFolder, type SubFolder, type JoinDocResult } from './overleafSocket' @@ -136,6 +136,106 @@ ipcMain.handle('synctex:editFromPdf', async (_e, pdfPath: string, page: number, }) }) +// SyncTeX: source file:line → PDF page/position (forward search) +ipcMain.handle('synctex:viewFromSource', async (_e, line: number, col: number, relPath: string) => { + const syncDir = compilationManager?.dir + if (!syncDir) return null + // Look for build dir output.pdf + const buildDir = join(syncDir, '.build') + const pdfPath = join(buildDir, 'output.pdf') + const filePath = join(syncDir, relPath) + const input = `${line}:${col}:${filePath}` + console.log(`[synctex] view -i ${input} -o ${pdfPath}`) + return new Promise<{ page: number; x: number; y: number; h: number; v: number; W: number; H: number } | null>((resolve) => { + const proc = spawn('synctex', ['view', '-i', input, '-o', pdfPath], { + env: process.env, + cwd: syncDir + }) + let stdout = '' + let stderr = '' + proc.stdout?.on('data', (d) => { stdout += d.toString() }) + proc.stderr?.on('data', (d) => { stderr += d.toString() }) + proc.on('close', (code) => { + console.log(`[synctex] view exit=${code} stdout=${stdout.slice(0, 300)} stderr=${stderr.slice(0, 200)}`) + const pageMatch = stdout.match(/Page:(\d+)/) + const xMatch = stdout.match(/x:([0-9.]+)/) + const yMatch = stdout.match(/y:([0-9.]+)/) + const hMatch = stdout.match(/h:([0-9.]+)/) + const vMatch = stdout.match(/v:([0-9.]+)/) + const wMatch = stdout.match(/W:([0-9.]+)/) + const hMatch2 = stdout.match(/H:([0-9.]+)/) + if (pageMatch) { + resolve({ + page: parseInt(pageMatch[1]), + x: xMatch ? parseFloat(xMatch[1]) : 0, + y: yMatch ? parseFloat(yMatch[1]) : 0, + h: hMatch ? parseFloat(hMatch[1]) : 0, + v: vMatch ? parseFloat(vMatch[1]) : 0, + W: wMatch ? parseFloat(wMatch[1]) : 0, + H: hMatch2 ? parseFloat(hMatch2[1]) : 0 + }) + } else { + resolve(null) + } + }) + proc.on('error', (err) => { + console.log(`[synctex] view spawn error: ${err.message}`) + resolve(null) + }) + }) +}) + +// ── Multi-file search ──────────────────────────────────────────── + +const TEXT_EXTS = new Set(['.tex', '.bib', '.sty', '.cls', '.bst', '.txt', '.md', '.cfg', '.def', '.dtx', '.ins', '.ltx']) + +async function walkDir(dir: string, base: string): Promise<string[]> { + const results: string[] = [] + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const full = join(dir, entry.name) + if (entry.isDirectory()) { + results.push(...await walkDir(full, base)) + } else if (TEXT_EXTS.has(extname(entry.name).toLowerCase())) { + results.push(relative(base, full)) + } + } + return results +} + +ipcMain.handle('search:files', async (_e, query: string, caseSensitive: boolean) => { + const syncDir = compilationManager?.dir + if (!syncDir || !query) return [] + + const files = await walkDir(syncDir, syncDir) + const results: Array<{ file: string; line: number; content: string; col: number }> = [] + const flags = caseSensitive ? 'g' : 'gi' + let regex: RegExp + try { + regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags) + } catch { + return [] + } + + for (const relPath of files) { + if (results.length >= 200) break + try { + const content = await readFile(join(syncDir, relPath), 'utf-8') + const lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + if (results.length >= 200) break + const match = regex.exec(lines[i]) + if (match) { + results.push({ file: relPath, line: i + 1, content: lines[i].trim().slice(0, 200), col: match.index }) + regex.lastIndex = 0 // reset for next line + } + } + } catch { /* skip unreadable files */ } + } + return results +}) + // ── Terminal / PTY ─────────────────────────────────────────────── ipcMain.handle('pty:spawn', async (_e, id: string, cwd: string, cmd?: string, args?: string[]) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 649c3aa..6bdcdbb 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -41,6 +41,12 @@ const api = { // SyncTeX synctexEdit: (pdfPath: string, page: number, x: number, y: number) => ipcRenderer.invoke('synctex:editFromPdf', pdfPath, page, x, y) as Promise<{ file: string; line: number } | null>, + synctexView: (line: number, col: number, relPath: string) => + ipcRenderer.invoke('synctex:viewFromSource', line, col, relPath) as Promise<{ page: number; x: number; y: number; h: number; v: number; W: number; H: number } | null>, + + // Multi-file search + searchFiles: (query: string, caseSensitive: boolean) => + ipcRenderer.invoke('search:files', query, caseSensitive) as Promise<Array<{ file: string; line: number; content: string; col: number }>>, // Overleaf web session (comments) overleafWebLogin: () => ipcRenderer.invoke('overleaf:webLogin') as Promise<{ success: boolean }>, diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index cdf4bce..7f7b24c 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -349,6 +349,115 @@ html, body, #root { min-height: 0; } +/* ── Search Panel ────────────────────────────────────────────── */ + +.search-panel { + height: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); +} + +.search-panel-header { + padding: 8px; + border-bottom: 1px solid var(--border); +} + +.search-input-row { + display: flex; + gap: 4px; +} + +.search-input { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + padding: 5px 8px; + outline: none; +} + +.search-input:focus { + border-color: var(--accent-blue); +} + +.search-case-btn { + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + padding: 2px 6px; + cursor: pointer; +} + +.search-case-btn.active { + background: var(--accent-blue); + color: white; + border-color: var(--accent-blue); +} + +.search-status { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + padding: 0 2px; +} + +.search-results { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.search-file-group { + border-bottom: 1px solid var(--border); +} + +.search-file-name { + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + color: var(--accent); + background: var(--bg-tertiary); + position: sticky; + top: 0; + z-index: 1; +} + +.search-result-item { + display: flex; + gap: 8px; + padding: 3px 8px 3px 16px; + font-size: 12px; + font-family: var(--font-mono); + cursor: pointer; + align-items: baseline; +} + +.search-result-item:hover { + background: var(--bg-hover); +} + +.search-result-line { + color: var(--text-muted); + font-size: 10px; + min-width: 28px; + text-align: right; + flex-shrink: 0; +} + +.search-result-content { + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* ── File Tree ───────────────────────────────────────────────── */ .file-tree { @@ -1271,6 +1380,61 @@ html, body, #root { height: 100%; } +/* CodeMirror search panel — Cosmic Latte theme */ +.cm-editor .cm-search { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + padding: 4px 8px; + font-family: var(--font-sans); + font-size: 12px; + color: var(--text-primary); + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} +.cm-editor .cm-search input, +.cm-editor .cm-search select { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 6px; + outline: none; +} +.cm-editor .cm-search input:focus { + border-color: var(--accent-blue); +} +.cm-editor .cm-search button { + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 11px; + padding: 2px 8px; + cursor: pointer; +} +.cm-editor .cm-search button:hover { + background: var(--bg-active); +} +.cm-editor .cm-search label { + font-size: 11px; + color: var(--text-secondary); +} +.cm-editor .cm-search .cm-button { + background: var(--bg-hover); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.cm-editor .cm-searchMatch { + background: rgba(184, 134, 11, 0.3); +} +.cm-editor .cm-searchMatch-selected { + background: rgba(184, 134, 11, 0.6); +} + /* ── PDF Viewer ──────────────────────────────────────────────── */ .pdf-panel { @@ -1328,6 +1492,38 @@ html, body, #root { text-align: center; } +.pdf-search-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); +} + +.pdf-search-input { + flex: 1; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + padding: 3px 8px; + outline: none; +} + +.pdf-search-input:focus { + border-color: var(--accent-blue); +} + +.pdf-search-count { + font-size: 11px; + color: var(--text-muted); + min-width: 60px; + text-align: center; +} + .pdf-container { flex: 1; overflow: auto; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6992a17..1aedbd6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import PdfViewer from './components/PdfViewer' import Terminal from './components/Terminal' import ReviewPanel from './components/ReviewPanel' import ChatPanel from './components/ChatPanel' +import SearchPanel from './components/SearchPanel' import StatusBar from './components/StatusBar' import type { OverleafDocSync } from './ot/overleafSync' import { colorForUser, type RemoteCursor } from './extensions/remoteCursors' @@ -49,6 +50,7 @@ export default function App() { showFileTree, showReviewPanel, showChat, + showSearch, } = useAppStore() const [checkingSession, setCheckingSession] = useState(true) @@ -206,12 +208,39 @@ export default function App() { e.preventDefault() useAppStore.getState().toggleTerminal() } + if (e.key === 'Enter') { + e.preventDefault() + handleForwardSearch() + } + if (e.key === 'f' && e.shiftKey) { + e.preventDefault() + useAppStore.getState().toggleSearch() + } } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [screen]) + const handleForwardSearch = async () => { + const state = useAppStore.getState() + const activeTab = state.activeTab + if (!activeTab) return + // Get cursor line from the active editor view + const docId = state.pathDocMap[activeTab] + const sync = docId ? activeDocSyncs.get(docId) : null + const view = sync?.editorView + if (!view) return + const cursor = view.state.selection.main.head + const line = view.state.doc.lineAt(cursor) + const lineNum = line.number + const col = cursor - line.from + const result = await window.api.synctexView(lineNum, col, activeTab) + if (result) { + state.setPendingPdfGoTo({ page: result.page, y: result.v }) + } + } + const handleCompile = async () => { const state = useAppStore.getState() const mainDoc = state.mainDocument || state.overleafProject?.rootDocId @@ -383,13 +412,17 @@ export default function App() { <Toolbar onCompile={handleCompile} onLocalCompile={handleLocalCompile} onBack={handleBackToProjects} /> <div className="main-content"> <PanelGroup direction="horizontal"> - {showFileTree && ( + {(showFileTree || showSearch) && ( <> <Panel defaultSize={18} minSize={12} maxSize={35}> - <div className="sidebar-panel"> - <FileTree /> - <OutlineView /> - </div> + {showSearch ? ( + <SearchPanel /> + ) : ( + <div className="sidebar-panel"> + <FileTree /> + <OutlineView /> + </div> + )} </Panel> <PanelResizeHandle className="resize-handle resize-handle-h" /> </> diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 91b944c..d3a1e3f 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -7,7 +7,7 @@ import { EditorState } from '@codemirror/state' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { bracketMatching, foldGutter, indentOnInput, StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language' import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' -import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' +import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search' import { stex } from '@codemirror/legacy-modes/mode/stex' import { tags } from '@lezer/highlight' import { useAppStore } from '../stores/appStore' @@ -239,6 +239,7 @@ export default function Editor() { foldGutter(), history(), highlightSelectionMatches(), + search({ top: true }), StreamLanguage.define(stex), syntaxHighlighting(cosmicLatteHighlight), keymap.of([ diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx index 36c5f80..ef37b5b 100644 --- a/src/renderer/src/components/PdfViewer.tsx +++ b/src/renderer/src/components/PdfViewer.tsx @@ -128,6 +128,7 @@ type LogFilter = 'all' | 'error' | 'warning' export default function PdfViewer() { const { pdfPath, compileLog, compiling } = useAppStore() + const pendingPdfGoTo = useAppStore((s) => s.pendingPdfGoTo) const containerRef = useRef<HTMLDivElement>(null) // scroll viewport const wrapperRef = useRef<HTMLDivElement>(null) // inner wrapper (CSS transform target) const [scale, setScale] = useState(1.0) @@ -143,6 +144,15 @@ export default function PdfViewer() { const prevCompilingRef = useRef(false) const renderingRef = useRef(false) + // PDF text search state + const [pdfSearchQuery, setPdfSearchQuery] = useState('') + const [pdfSearchVisible, setPdfSearchVisible] = useState(false) + const [pdfSearchResults, setPdfSearchResults] = useState<Array<{ page: number; index: number }>>([]) + const [pdfSearchCurrent, setPdfSearchCurrent] = useState(-1) + const pdfTextCache = useRef<Map<number, string>>(new Map()) + const pdfSearchInputRef = useRef<HTMLInputElement>(null) + const pdfDocRef = useRef<any>(null) + // Parse and sort log entries (errors first, then warnings) const logEntries = compileLog ? parseCompileLog(compileLog) : [] const levelOrder = { error: 0, warning: 1, info: 2 } @@ -294,6 +304,8 @@ export default function PdfViewer() { const arrayBuffer = await window.api.readBinary(pdfPath) const data = new Uint8Array(arrayBuffer) const pdf = await pdfjsLib.getDocument({ data }).promise + pdfDocRef.current = pdf + pdfTextCache.current.clear() setNumPages(pdf.numPages) const wrapper = wrapperRef.current @@ -407,6 +419,153 @@ export default function PdfViewer() { renderPdf() }, [renderPdf]) + // PDF search: Cmd+F when on PDF tab opens search bar + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'f' && tab === 'pdf') { + // Only if focus is in the PDF area (not editor) + const active = document.activeElement + const inEditor = active?.closest('.cm-editor') + if (inEditor) return + e.preventDefault() + setPdfSearchVisible(true) + setTimeout(() => pdfSearchInputRef.current?.focus(), 50) + } + if (e.key === 'Escape' && pdfSearchVisible) { + setPdfSearchVisible(false) + setPdfSearchQuery('') + setPdfSearchResults([]) + setPdfSearchCurrent(-1) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [tab, pdfSearchVisible]) + + // PDF search: extract text and find matches + useEffect(() => { + if (!pdfSearchQuery.trim() || !pdfDocRef.current) { + setPdfSearchResults([]) + setPdfSearchCurrent(-1) + return + } + + const search = async () => { + const pdf = pdfDocRef.current + if (!pdf) return + const q = pdfSearchQuery.toLowerCase() + const results: Array<{ page: number; index: number }> = [] + + for (let i = 1; i <= pdf.numPages; i++) { + let text = pdfTextCache.current.get(i) + if (!text) { + try { + const page = await pdf.getPage(i) + const tc = await page.getTextContent() + text = tc.items.map((item: any) => item.str).join(' ') + pdfTextCache.current.set(i, text) + } catch { + continue + } + } + const lower = text.toLowerCase() + let pos = 0 + while ((pos = lower.indexOf(q, pos)) !== -1) { + results.push({ page: i, index: pos }) + pos += q.length + if (results.length >= 500) break + } + if (results.length >= 500) break + } + + setPdfSearchResults(results) + setPdfSearchCurrent(results.length > 0 ? 0 : -1) + } + + const timer = setTimeout(search, 200) + return () => clearTimeout(timer) + }, [pdfSearchQuery]) + + // PDF search: scroll to current result + useEffect(() => { + if (pdfSearchCurrent < 0 || pdfSearchCurrent >= pdfSearchResults.length) return + const result = pdfSearchResults[pdfSearchCurrent] + if (!result || !wrapperRef.current || !containerRef.current) return + + const canvases = wrapperRef.current.querySelectorAll('canvas.pdf-page') + const canvas = canvases[result.page - 1] as HTMLCanvasElement | undefined + if (!canvas) return + + const container = containerRef.current + const containerRect = container.getBoundingClientRect() + const canvasRect = canvas.getBoundingClientRect() + const offsetInContainer = canvasRect.top - containerRect.top + container.scrollTop + + // Scroll to roughly the right area of the page + container.scrollTo({ top: Math.max(0, offsetInContainer - containerRect.height / 3), behavior: 'smooth' }) + }, [pdfSearchCurrent, pdfSearchResults]) + + const pdfSearchNext = () => { + if (pdfSearchResults.length === 0) return + setPdfSearchCurrent((c) => (c + 1) % pdfSearchResults.length) + } + + const pdfSearchPrev = () => { + if (pdfSearchResults.length === 0) return + setPdfSearchCurrent((c) => (c - 1 + pdfSearchResults.length) % pdfSearchResults.length) + } + + // Handle forward SyncTeX navigation (scroll PDF to page+position) + useEffect(() => { + if (!pendingPdfGoTo || !wrapperRef.current || !containerRef.current) return + const { page, y } = pendingPdfGoTo + useAppStore.getState().setPendingPdfGoTo(null) + + // Switch to PDF tab + setTab('pdf') + + // Wait a frame for tab switch / render + requestAnimationFrame(() => { + const wrapper = wrapperRef.current + const container = containerRef.current + if (!wrapper || !container) return + + const canvases = wrapper.querySelectorAll('canvas.pdf-page') + const targetCanvas = canvases[page - 1] as HTMLCanvasElement | undefined + if (!targetCanvas) return + + const vpInfo = pageViewportsRef.current.get(page) + if (!vpInfo) return + + // y is in PDF points from top; convert to fraction of page height + const yFrac = y / vpInfo.height + const cssScale = scaleRef.current / renderedScaleRef.current + + // Calculate scroll position + const canvasRect = targetCanvas.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const offsetInContainer = canvasRect.top - containerRect.top + container.scrollTop + const targetY = offsetInContainer + yFrac * canvasRect.height - containerRect.height / 3 + + container.scrollTo({ top: Math.max(0, targetY), behavior: 'smooth' }) + + // Flash highlight on the line + const highlight = document.createElement('div') + highlight.style.cssText = ` + position: absolute; left: 0; right: 0; + height: ${14 * cssScale}px; + top: ${canvasRect.top - containerRect.top + container.scrollTop + yFrac * canvasRect.height}px; + background: rgba(74, 111, 165, 0.3); + pointer-events: none; z-index: 10; + transition: opacity 1.5s ease-out; + ` + container.style.position = 'relative' + container.appendChild(highlight) + setTimeout(() => { highlight.style.opacity = '0' }, 500) + setTimeout(() => { highlight.remove() }, 2000) + }) + }, [pendingPdfGoTo]) + // Empty state if (!pdfPath && !compileLog) { return ( @@ -443,6 +602,17 @@ export default function PdfViewer() { <span className="pdf-scale">{Math.round(scale * 100)}%</span> <button className="toolbar-btn" onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button> <button className="toolbar-btn" onClick={() => setScale(1.0)}>Fit</button> + <button + className={`toolbar-btn ${pdfSearchVisible ? 'active' : ''}`} + onClick={() => { + setPdfSearchVisible(!pdfSearchVisible) + if (!pdfSearchVisible) setTimeout(() => pdfSearchInputRef.current?.focus(), 50) + else { setPdfSearchQuery(''); setPdfSearchResults([]); setPdfSearchCurrent(-1) } + }} + title="Search in PDF (Cmd+F)" + > + ⌕ + </button> {pdfPath && ( <button className="toolbar-btn" onClick={() => window.api.savePdf(pdfPath)} title="Download PDF"> ↓ @@ -465,6 +635,43 @@ export default function PdfViewer() { )} </div> + {/* PDF search bar */} + {pdfSearchVisible && tab === 'pdf' && ( + <div className="pdf-search-bar"> + <input + ref={pdfSearchInputRef} + className="pdf-search-input" + type="text" + placeholder="Search in PDF..." + value={pdfSearchQuery} + onChange={(e) => setPdfSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && e.shiftKey) pdfSearchPrev() + else if (e.key === 'Enter') pdfSearchNext() + else if (e.key === 'Escape') { + setPdfSearchVisible(false) + setPdfSearchQuery('') + setPdfSearchResults([]) + setPdfSearchCurrent(-1) + } + }} + /> + <span className="pdf-search-count"> + {pdfSearchResults.length > 0 + ? `${pdfSearchCurrent + 1}/${pdfSearchResults.length}` + : pdfSearchQuery ? 'No results' : ''} + </span> + <button className="toolbar-btn" onClick={pdfSearchPrev} disabled={pdfSearchResults.length === 0} title="Previous (Shift+Enter)">↑</button> + <button className="toolbar-btn" onClick={pdfSearchNext} disabled={pdfSearchResults.length === 0} title="Next (Enter)">↓</button> + <button className="toolbar-btn" onClick={() => { + setPdfSearchVisible(false) + setPdfSearchQuery('') + setPdfSearchResults([]) + setPdfSearchCurrent(-1) + }} title="Close">✕</button> + </div> + )} + {/* PDF view — always mounted, hidden when log is shown */} <div className="pdf-container" ref={containerRef} style={{ display: tab === 'pdf' ? undefined : 'none' }}> <div className="pdf-wrapper" ref={wrapperRef} /> diff --git a/src/renderer/src/components/SearchPanel.tsx b/src/renderer/src/components/SearchPanel.tsx new file mode 100644 index 0000000..2655d41 --- /dev/null +++ b/src/renderer/src/components/SearchPanel.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Yuren Hao +// Licensed under AGPL-3.0 - see LICENSE file + +import { useState, useRef, useEffect, useCallback } from 'react' +import { useAppStore } from '../stores/appStore' + +interface SearchResult { + file: string + line: number + content: string + col: number +} + +export default function SearchPanel() { + const [query, setQuery] = useState('') + const [caseSensitive, setCaseSensitive] = useState(false) + const [results, setResults] = useState<SearchResult[]>([]) + const [searching, setSearching] = useState(false) + const inputRef = useRef<HTMLInputElement>(null) + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) + + useEffect(() => { + inputRef.current?.focus() + }, []) + + const doSearch = useCallback(async (q: string, cs: boolean) => { + if (!q.trim()) { + setResults([]) + return + } + setSearching(true) + const res = await window.api.searchFiles(q, cs) + setResults(res) + setSearching(false) + }, []) + + const handleInputChange = (val: string) => { + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val, caseSensitive), 300) + } + + const handleCaseSensitiveToggle = () => { + const newCs = !caseSensitive + setCaseSensitive(newCs) + if (query.trim()) doSearch(query, newCs) + } + + const handleResultClick = async (result: SearchResult) => { + const store = useAppStore.getState() + + if (store.fileContents[result.file]) { + store.openFile(result.file, result.file.split('/').pop() || result.file) + store.setPendingGoTo({ file: result.file, line: result.line }) + return + } + + const docId = store.pathDocMap[result.file] + if (docId) { + try { + const joinResult = await window.api.otJoinDoc(docId) + if (joinResult.success && joinResult.content !== undefined) { + useAppStore.getState().setFileContent(result.file, joinResult.content) + if (joinResult.version !== undefined) { + useAppStore.getState().setDocVersion(docId, joinResult.version) + } + useAppStore.getState().openFile(result.file, result.file.split('/').pop() || result.file) + useAppStore.getState().setPendingGoTo({ file: result.file, line: result.line }) + } + } catch { /* failed to join doc */ } + } + } + + // Group results by file + const grouped = new Map<string, SearchResult[]>() + for (const r of results) { + const list = grouped.get(r.file) || [] + list.push(r) + grouped.set(r.file, list) + } + + return ( + <div className="search-panel"> + <div className="search-panel-header"> + <div className="search-input-row"> + <input + ref={inputRef} + className="search-input" + type="text" + placeholder="Search in files..." + value={query} + onChange={(e) => handleInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') useAppStore.getState().toggleSearch() + if (e.key === 'Enter') doSearch(query, caseSensitive) + }} + /> + <button + className={`search-case-btn ${caseSensitive ? 'active' : ''}`} + onClick={handleCaseSensitiveToggle} + title="Case sensitive" + > + Aa + </button> + </div> + {query && ( + <div className="search-status"> + {searching ? 'Searching...' : `${results.length} result${results.length !== 1 ? 's' : ''} in ${grouped.size} file${grouped.size !== 1 ? 's' : ''}`} + {results.length >= 200 && ' (limited)'} + </div> + )} + </div> + <div className="search-results"> + {[...grouped.entries()].map(([file, matches]) => ( + <div key={file} className="search-file-group"> + <div className="search-file-name">{file}</div> + {matches.map((r, i) => ( + <div + key={i} + className="search-result-item" + onClick={() => handleResultClick(r)} + > + <span className="search-result-line">{r.line}</span> + <span className="search-result-content">{r.content}</span> + </div> + ))} + </div> + ))} + </div> + </div> + ) +} diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 94ddb5e..0cee7e1 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -14,6 +14,7 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr const { compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree, showReviewPanel, toggleReviewPanel, showChat, toggleChat, + showSearch, toggleSearch, connectionState, overleafProject, onlineUsersCount } = useAppStore() @@ -48,6 +49,9 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr <button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree"> {showFileTree ? '◧' : '☰'} </button> + <button className={`toolbar-btn ${showSearch ? 'active' : ''}`} onClick={toggleSearch} title="Search in files (Cmd+Shift+F)"> + Search + </button> <span className="project-name"> <span className={`connection-dot ${connectionDot}`} title={connectionState} /> {projectName} diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts index e288c56..e9cb749 100644 --- a/src/renderer/src/ot/overleafSync.ts +++ b/src/renderer/src/ot/overleafSync.ts @@ -36,6 +36,10 @@ export class OverleafDocSync { return this.otClient.version } + get editorView(): EditorView | null { + return this.view + } + setView(view: EditorView) { this.view = view } diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts index 9a1d441..6b3b6cd 100644 --- a/src/renderer/src/stores/appStore.ts +++ b/src/renderer/src/stores/appStore.ts @@ -94,6 +94,10 @@ interface AppState { syncDir: string setSyncDir: (dir: string) => void + // Search panel + showSearch: boolean + toggleSearch: () => void + // Review panel showReviewPanel: boolean toggleReviewPanel: () => void @@ -121,6 +125,8 @@ interface AppState { // Navigation pendingGoTo: { file: string; line?: number; pos?: number; highlight?: string } | null setPendingGoTo: (g: { file: string; line?: number; pos?: number; highlight?: string } | null) => void + pendingPdfGoTo: { page: number; y: number } | null + setPendingPdfGoTo: (g: { page: number; y: number } | null) => void // Status statusMessage: string @@ -210,6 +216,9 @@ export const useAppStore = create<AppState>((set) => ({ syncDir: '', setSyncDir: (dir) => set({ syncDir: dir }), + showSearch: false, + toggleSearch: () => set((s) => ({ showSearch: !s.showSearch })), + showReviewPanel: false, toggleReviewPanel: () => set((s) => ({ showReviewPanel: !s.showReviewPanel })), @@ -232,6 +241,8 @@ export const useAppStore = create<AppState>((set) => ({ pendingGoTo: null, setPendingGoTo: (g) => set({ pendingGoTo: g }), + pendingPdfGoTo: null, + setPendingPdfGoTo: (g) => set({ pendingPdfGoTo: g }), statusMessage: 'Ready', setStatusMessage: (m) => set({ statusMessage: m }), @@ -245,6 +256,7 @@ export const useAppStore = create<AppState>((set) => ({ pdfPath: null, compileLog: '', compiling: false, + showSearch: false, overleafProjectId: null, connectionState: 'disconnected', docPathMap: {}, @@ -260,6 +272,7 @@ export const useAppStore = create<AppState>((set) => ({ hoveredThreadId: null, focusedThreadId: null, pendingGoTo: null, + pendingPdfGoTo: null, statusMessage: 'Ready', showChat: false, onlineUsersCount: 0 |
