diff --git a/e2e/pdf-search.spec.ts b/e2e/pdf-search.spec.ts new file mode 100644 index 0000000..417b04e --- /dev/null +++ b/e2e/pdf-search.spec.ts @@ -0,0 +1,97 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { expect, test } from './fixtures'; + +test('Cmd+F opens search bar in PDF viewer', { timeout: 60_000 }, async ({ electronApp, window }) => { + // Create the test PDF in the test's Node.js context (where imports work) + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([612, 792]); + const lines = [ + { text: 'Attention Is All You Need', size: 18 }, + { text: '', size: 11 }, + { text: 'Abstract', size: 14 }, + { text: '', size: 11 }, + { text: 'The dominant sequence transduction models are based on complex recurrent', size: 11 }, + { text: 'or convolutional neural networks that include an encoder and a decoder.', size: 11 }, + { text: 'We propose a new simple network architecture, the Transformer, based', size: 11 }, + { text: 'solely on attention mechanisms, dispensing with recurrence entirely.', size: 11 }, + ]; + let y = 720; + for (const line of lines) { + page.drawText(line.text, { x: 72, y, size: line.size, font, color: rgb(0, 0, 0) }); + y -= line.size + 6; + } + const pdfBytes = await doc.save(); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'papershelf-pdf-')); + const pdfPath = path.join(tmpDir, 'test-paper.pdf'); + fs.writeFileSync(pdfPath, pdfBytes); + + // Seed the paper in the main process DB (only DB access needs evaluate) + // Note: electronApp.evaluate passes the Electron module as the first arg; + // the data argument comes second (see take-screenshots.ts for the pattern) + await electronApp.evaluate( + async (_electron, _params: { pdfPath: string }) => { + const db = (global as Record).__papershelf_db; + db.insertPaper({ + arxivId: '1706.03762', + title: 'Attention Is All You Need', + authors: ['Ashish Vaswani', 'Noam Shazeer'], + abstract: 'The dominant sequence transduction models are based on complex recurrent neural networks.', + publishedDate: '2017-06-12T00:00:00Z', + updatedDate: '2017-06-12T00:00:00Z', + categories: ['cs.CL', 'cs.AI'], + arxivUrl: 'https://arxiv.org/abs/1706.03762', + pdfUrl: 'https://arxiv.org/pdf/1706.03762', + pdfPath: _params.pdfPath, + fullText: null, + }); + }, + { pdfPath }, + ); + + // Navigate to library and click the paper + await window.getByRole('button', { name: 'My Library' }).click(); + await expect(window.getByText('Attention Is All You Need').first()).toBeVisible({ timeout: 5000 }); + await window.getByText('Attention Is All You Need').first().click(); + + // Wait for PDF to fully render — the loading indicator is transient and may + // disappear before we can assert on it, so just wait for the text layer + await window.locator('.textLayer span').first().waitFor({ timeout: 30000 }); + + // Take a screenshot of the PDF viewer before search + await window.screenshot({ path: 'test-results/pdf-viewer-before-search.png' }); + + // Open search with Cmd+F + await window.keyboard.press('Meta+f'); + + // Search bar should be visible + const searchInput = window.getByPlaceholder('Find in PDF...'); + await expect(searchInput).toBeVisible({ timeout: 3000 }); + + // Take screenshot showing search bar open + await window.screenshot({ path: 'test-results/pdf-search-bar-open.png' }); + + // Type a search query + await searchInput.fill('attention'); + + // Wait for match counter to appear + await expect(window.getByText(/\d+ of \d+/)).toBeVisible({ timeout: 5000 }); + + // Take screenshot showing search results with highlights + await window.screenshot({ path: 'test-results/pdf-search-results.png' }); + + // Navigate to next match with Enter + await searchInput.press('Enter'); + await window.screenshot({ path: 'test-results/pdf-search-next-match.png' }); + + // Close search with Escape + await searchInput.press('Escape'); + await expect(searchInput).not.toBeVisible(); + + // Take screenshot after closing search (highlights should be gone) + await window.screenshot({ path: 'test-results/pdf-search-closed.png' }); +}); diff --git a/src/renderer/__tests__/shortcut-store.test.ts b/src/renderer/__tests__/shortcut-store.test.ts new file mode 100644 index 0000000..4d488e7 --- /dev/null +++ b/src/renderer/__tests__/shortcut-store.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(() => null), +}); + +// Mock electronAPI for store methods that call it +vi.stubGlobal('window', { + ...globalThis.window, + electronAPI: { + getShortcutOverrides: vi.fn(() => Promise.resolve({})), + saveShortcutOverrides: vi.fn(), + }, +}); + +import { buildKeyString, formatKeys, getDefaultKeys, useShortcutStore } from '../stores/shortcutStore'; + +function fakeKeyEvent(overrides: Partial): KeyboardEvent { + return { + key: '', + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides, + } as KeyboardEvent; +} + +beforeEach(() => { + useShortcutStore.getState().resetAll(); +}); + +// --- buildKeyString --- + +describe('buildKeyString', () => { + it('returns null for lone modifier presses', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'Meta', metaKey: true }))).toBeNull(); + expect(buildKeyString(fakeKeyEvent({ key: 'Shift', shiftKey: true }))).toBeNull(); + expect(buildKeyString(fakeKeyEvent({ key: 'Alt', altKey: true }))).toBeNull(); + expect(buildKeyString(fakeKeyEvent({ key: 'Control', ctrlKey: true }))).toBeNull(); + }); + + it('returns null when no modifier is held', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'f' }))).toBeNull(); + expect(buildKeyString(fakeKeyEvent({ key: 'Enter' }))).toBeNull(); + }); + + it('builds Meta+key strings', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true }))).toBe('Meta+f'); + expect(buildKeyString(fakeKeyEvent({ key: 'b', metaKey: true }))).toBe('Meta+b'); + }); + + it('lowercases single-char keys', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'F', metaKey: true }))).toBe('Meta+f'); + }); + + it('preserves multi-char key names', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'Enter', metaKey: true }))).toBe('Meta+Enter'); + expect(buildKeyString(fakeKeyEvent({ key: 'Escape', ctrlKey: true }))).toBe('Control+Escape'); + }); + + it('combines multiple modifiers in order', () => { + expect(buildKeyString(fakeKeyEvent({ key: 'g', metaKey: true, shiftKey: true }))).toBe('Meta+Shift+g'); + expect(buildKeyString(fakeKeyEvent({ key: 'z', metaKey: true, altKey: true }))).toBe('Meta+Alt+z'); + }); +}); + +// --- formatKeys --- + +describe('formatKeys', () => { + it('formats Meta as ⌘', () => { + expect(formatKeys('Meta+f')).toBe('⌘F'); + }); + + it('formats Shift as ⇧', () => { + expect(formatKeys('Meta+Shift+g')).toBe('⌘⇧G'); + }); + + it('formats Alt as ⌥', () => { + expect(formatKeys('Meta+Alt+z')).toBe('⌘⌥Z'); + }); + + it('formats Control as ⌃', () => { + expect(formatKeys('Control+c')).toBe('⌃C'); + }); +}); + +// --- Default shortcuts --- + +describe('default shortcuts', () => { + it('includes findInPdf shortcut', () => { + const shortcut = useShortcutStore.getState().getShortcut('findInPdf'); + expect(shortcut).toBeDefined(); + expect(shortcut?.keys).toBe('Meta+f'); + expect(shortcut?.label).toBe('Find in PDF'); + }); + + it('includes highlightSelection shortcut', () => { + const shortcut = useShortcutStore.getState().getShortcut('highlightSelection'); + expect(shortcut).toBeDefined(); + expect(shortcut?.keys).toBe('Meta+e'); + }); + + it('returns undefined for unknown shortcut IDs', () => { + expect(useShortcutStore.getState().getShortcut('nonexistent')).toBeUndefined(); + }); + + it('getDefaultKeys returns the default for known IDs', () => { + expect(getDefaultKeys('findInPdf')).toBe('Meta+f'); + expect(getDefaultKeys('toggleSidebar')).toBe('Meta+b'); + }); + + it('getDefaultKeys returns undefined for unknown IDs', () => { + expect(getDefaultKeys('nonexistent')).toBeUndefined(); + }); +}); + +// --- Shortcut remapping --- + +describe('shortcut remapping', () => { + it('remaps a shortcut and finds it by new keys', () => { + const store = useShortcutStore.getState(); + const result = store.setShortcutKeys('findInPdf', 'Meta+Shift+f'); + expect(result.success).toBe(true); + + const updated = useShortcutStore.getState().getShortcut('findInPdf'); + expect(updated?.keys).toBe('Meta+Shift+f'); + }); + + it('detects conflicts when remapping to an existing key', () => { + const store = useShortcutStore.getState(); + // Try to remap findInPdf to Meta+b which is toggleSidebar + const result = store.setShortcutKeys('findInPdf', 'Meta+b'); + expect(result.success).toBe(false); + expect(result.conflict).toBe('Toggle Sidebar'); + }); + + it('resets a shortcut to default', () => { + const store = useShortcutStore.getState(); + store.setShortcutKeys('findInPdf', 'Meta+Shift+f'); + expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+Shift+f'); + + useShortcutStore.getState().resetShortcut('findInPdf'); + expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+f'); + }); + + it('resetAll restores all defaults', () => { + const store = useShortcutStore.getState(); + store.setShortcutKeys('findInPdf', 'Meta+Shift+f'); + store.setShortcutKeys('toggleSidebar', 'Meta+Shift+b'); + + useShortcutStore.getState().resetAll(); + + expect(useShortcutStore.getState().getShortcut('findInPdf')?.keys).toBe('Meta+f'); + expect(useShortcutStore.getState().getShortcut('toggleSidebar')?.keys).toBe('Meta+b'); + }); + + it('remapped shortcut is found via key lookup (simulating keyboard dispatch)', () => { + useShortcutStore.getState().setShortcutKeys('findInPdf', 'Meta+Shift+f'); + + const keyString = buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true, shiftKey: true })); + const match = useShortcutStore.getState().shortcuts.find((s) => s.keys === keyString); + expect(match?.id).toBe('findInPdf'); + + // Old key should no longer match findInPdf + const oldKeyString = buildKeyString(fakeKeyEvent({ key: 'f', metaKey: true })); + const oldMatch = useShortcutStore.getState().shortcuts.find((s) => s.keys === oldKeyString); + expect(oldMatch?.id).not.toBe('findInPdf'); + }); +}); diff --git a/src/renderer/components/PaperDetail.tsx b/src/renderer/components/PaperDetail.tsx index c54d866..3132f02 100644 --- a/src/renderer/components/PaperDetail.tsx +++ b/src/renderer/components/PaperDetail.tsx @@ -5,8 +5,8 @@ import { formatKeys, useShortcutStore } from '../stores/shortcutStore'; import { useUIStore } from '../stores/uiStore'; import { ConfirmPopup } from './ConfirmPopup'; import { FolderPlusIcon, InfoCircleIcon, StarIcon, StarOutlineIcon, TagPlusIcon } from './Icons'; - import { PdfViewer } from './PdfViewer'; +import { Tooltip } from './ShortcutHint'; function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString('en-US', { @@ -117,131 +117,132 @@ export function PaperDetail() { return (
- {/* Compact header */} -
-
- {isLibraryPaper && ( - - )} - -

{paper.title}

- - {isLibraryPaper && ( - <> -
- - {showHeaderCollectionPicker && collections.length > 0 && ( -
- {collections.map((col) => { - const isIn = paperCollections.some((c) => c.id === col.id); - return ( + {/* Content: always PDF */} +
+ {hasPdf ? ( + + {isLibraryPaper && ( + + )} +

{paper.title}

+
+ } + headerActions={ + <> + {isLibraryPaper && ( + <> +
+ - ); - })} -
- )} -
- -
- - {showHeaderTagPicker && tags.length > 0 && ( -
- {tags.map((tag) => { - const has = paperTags.some((t) => t.id === tag.id); - return ( + + {showHeaderCollectionPicker && collections.length > 0 && ( +
+ {collections.map((col) => { + const isIn = paperCollections.some((c) => c.id === col.id); + return ( + + ); + })} +
+ )} +
+ +
+ - ); - })} -
+ + {showHeaderTagPicker && tags.length > 0 && ( +
+ {tags.map((tag) => { + const has = paperTags.some((t) => t.id === tag.id); + return ( + + ); + })} +
+ )} +
+ )} -
- - )} - - { - setShowInfoPopover((v) => !v); - setShowHeaderCollectionPicker(false); - setShowHeaderTagPicker(false); - }} - onClose={closeInfoPopover} - paper={paper} - isLibraryPaper={isLibraryPaper} - paperCollections={paperCollections} - paperTags={paperTags} - collections={collections} - tags={tags} - showCollectionPicker={showCollectionPicker} - setShowCollectionPicker={setShowCollectionPicker} - showTagPicker={showTagPicker} - setShowTagPicker={setShowTagPicker} - handleToggleCollection={handleToggleCollection} - handleToggleTag={handleToggleTag} - onDelete={paperId ? () => deletePaper(paperId) : undefined} - /> -
-

{paper.authors.join(', ')}

-
- - {/* Content: always PDF */} -
- {hasPdf ? ( - { + setShowInfoPopover((v) => !v); + setShowHeaderCollectionPicker(false); + setShowHeaderTagPicker(false); + }} + onClose={closeInfoPopover} + paper={paper} + isLibraryPaper={isLibraryPaper} + paperCollections={paperCollections} + paperTags={paperTags} + collections={collections} + tags={tags} + showCollectionPicker={showCollectionPicker} + setShowCollectionPicker={setShowCollectionPicker} + showTagPicker={showTagPicker} + setShowTagPicker={setShowTagPicker} + handleToggleCollection={handleToggleCollection} + handleToggleTag={handleToggleTag} + onDelete={paperId ? () => deletePaper(paperId) : undefined} + /> + + } /> ) : (
No PDF available
@@ -375,14 +376,15 @@ function InfoPopoverButton({ return (
- + + + {open && (
s.commandDown); const [scale, setScale] = useState(1.0); const [visualScale, setVisualScale] = useState(1.0); const [pdfVersion, setPdfVersion] = useState(0); @@ -198,6 +215,7 @@ export function PdfViewer({ paperId, pdfUrl, arxivId }: { paperId?: string; pdfU const [highlightToolbar, setHighlightToolbar] = useState(null); const [stickyNotePopup, setStickyNotePopup] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [showSearch, setShowSearch] = useState(false); const isPinching = useRef(false); const contentRef = useRef(null); @@ -409,31 +427,17 @@ export function PdfViewer({ paperId, pdfUrl, arxivId }: { paperId?: string; pdfU setVisualScale(1.0); }, [prepareScaleCommit]); - // Cmd+/Cmd- keyboard shortcuts - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setAnnotationMode('read'); - setHighlightToolbar(null); - setStickyNotePopup(null); - setDeleteConfirm(null); - return; - } - if (!event.metaKey) return; - if (event.key === '=' || event.key === '+') { - event.preventDefault(); - zoomIn(); - } else if (event.key === '-') { - event.preventDefault(); - zoomOut(); - } else if (event.key === '0') { - event.preventDefault(); - zoomReset(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [zoomIn, zoomOut, zoomReset]); + const closeSearch = useCallback(() => { + setShowSearch(false); + searchNavRef.current = null; + }, []); + const searchNavRef = useRef<{ goToNext: () => void; goToPrev: () => void } | null>(null); + const handleSearchNavigate = useCallback( + (handlers: { goToNext: () => void; goToPrev: () => void }) => { + searchNavRef.current = handlers; + }, + [], + ); // Pinch-to-zoom useEffect(() => { @@ -674,6 +678,65 @@ export function PdfViewer({ paperId, pdfUrl, arxivId }: { paperId?: string; pdfU setDeleteConfirm(null); }, []); + // Keyboard shortcuts (reads from shortcut store so remapping works) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (showSearch) { + setShowSearch(false); + return; + } + setAnnotationMode('read'); + setHighlightToolbar(null); + setStickyNotePopup(null); + setDeleteConfirm(null); + return; + } + // Zoom: fixed Cmd+/- shortcuts (not remappable) + if (event.metaKey) { + if (event.key === '=' || event.key === '+') { + zoomIn(); + return; + } + if (event.key === '-') { + zoomOut(); + return; + } + if (event.key === '0') { + zoomReset(); + return; + } + } + // Cmd+G / Cmd+Shift+G — find next/prev (window-level so it works without search bar focus) + if (event.metaKey && event.key === 'g') { + event.preventDefault(); + if (event.shiftKey) { + searchNavRef.current?.goToPrev(); + } else { + searchNavRef.current?.goToNext(); + } + return; + } + // Remappable shortcuts via store + const keyString = buildKeyString(event); + if (!keyString) return; + const shortcut = useShortcutStore.getState().shortcuts.find((s) => s.keys === keyString); + if (!shortcut) return; + switch (shortcut.id) { + case 'findInPdf': + event.preventDefault(); + setShowSearch(true); + break; + case 'highlightSelection': + event.preventDefault(); + toggleHighlightMode(); + break; + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showSearch, toggleHighlightMode, zoomIn, zoomOut, zoomReset]); + if (loading) { return (
@@ -692,60 +755,104 @@ export function PdfViewer({ paperId, pdfUrl, arxivId }: { paperId?: string; pdfU return (
-
- - - - {numPages > 0 && {numPages} pages} - - {!readOnly && ( -
- +
+
+
{headerTitle}
+
+ + + + {numPages > 0 && {numPages}p} +
+
+
+ {authors && authors.length > 0 && ( +

{authors.join(', ')}

+ )} + {commandDown && ( +
+ + ⌘+/⌘− + Zoom + + {showSearch && ( + + ⌘G + Find Next/Prev + + )} +
+ )} +
+ {headerActions} +
+ - + {!readOnly && ( + <> + + + + + + + + )}
- )} +
-
+
+ {showSearch && } +
{pages.map((page, index) => ( ))}
+
{highlightToolbar && ( diff --git a/src/renderer/components/ShortcutHint.tsx b/src/renderer/components/ShortcutHint.tsx index 2f884e3..72f20a9 100644 --- a/src/renderer/components/ShortcutHint.tsx +++ b/src/renderer/components/ShortcutHint.tsx @@ -1,7 +1,7 @@ import { type ReactNode, useCallback, useRef, useState } from 'react'; import { formatKeys, useShortcutStore } from '../stores/shortcutStore'; -const HOVER_DELAY_MS = 600; +const HOVER_DELAY_MS = 400; interface ShortcutHintProps { shortcutId: string; @@ -112,3 +112,64 @@ export function ShortcutHint({ ); } + +interface TooltipProps { + label: string; + children: ReactNode; + position?: 'below' | 'above'; + align?: 'center' | 'end'; +} + +export function Tooltip({ label, children, position = 'below', align = 'center' }: TooltipProps) { + const [hovered, setHovered] = useState(false); + const timerRef = useRef | null>(null); + + const onMouseEnter = useCallback(() => { + timerRef.current = setTimeout(() => setHovered(true), HOVER_DELAY_MS); + }, []); + + const onMouseLeave = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setHovered(false); + }, []); + + const isEnd = align === 'end'; + const posClass = position === 'above' + ? (isEnd ? 'bottom-full right-0 mb-0.5' : 'bottom-full left-1/2 mb-0.5') + : (isEnd ? 'top-full right-0 mt-0.5' : 'top-full left-1/2 mt-0.5'); + const transform = position === 'above' || position === 'below' + ? (isEnd ? undefined : 'translateX(-50%)') + : undefined; + const flexClass = position === 'above' + ? (isEnd ? 'flex-col items-end' : 'flex-col items-center') + : (isEnd ? 'flex-col items-end' : 'flex-col items-center'); + const arrow = position === 'above' ? ARROW_DOWN : ARROW; + + return ( + + {children} + {hovered && ( + + {position === 'above' ? ( + <> + + {label} + + {arrow} + + ) : ( + <> + {arrow} + + {label} + + + )} + + )} + + ); +} diff --git a/src/renderer/components/pdf/PdfSearchBar.tsx b/src/renderer/components/pdf/PdfSearchBar.tsx new file mode 100644 index 0000000..438249a --- /dev/null +++ b/src/renderer/components/pdf/PdfSearchBar.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface SearchMatch { + /** The text layer containing this match */ + span: HTMLElement; + /** Character offset within the span's textContent where the match starts */ + startOffset: number; + /** Length of the matched text */ + length: number; + /** Page index (0-based) */ + pageIndex: number; +} + +interface PdfSearchBarProps { + containerRef: React.RefObject; + onClose: () => void; + onNavigate?: (handlers: { goToNext: () => void; goToPrev: () => void }) => void; +} + +function findMatchesInTextLayers(container: HTMLElement, query: string): SearchMatch[] { + if (!query) return []; + + const lowerQuery = query.toLowerCase(); + const matches: SearchMatch[] = []; + + // Get all page wrappers in order + const pageWrappers = container.querySelectorAll('[data-page-index]'); + + for (const wrapper of pageWrappers) { + const pageIndex = Number.parseInt(wrapper.getAttribute('data-page-index') || '0', 10); + const textLayer = wrapper.querySelector('.textLayer'); + if (!textLayer) continue; + + const spans = textLayer.querySelectorAll('span'); + for (const span of spans) { + const text = span.textContent || ''; + const lowerText = text.toLowerCase(); + let searchFrom = 0; + + while (searchFrom < lowerText.length) { + const idx = lowerText.indexOf(lowerQuery, searchFrom); + if (idx === -1) break; + matches.push({ span, startOffset: idx, length: query.length, pageIndex }); + searchFrom = idx + 1; + } + } + } + + return matches; +} + +function clearHighlights(container: HTMLElement) { + for (const el of container.querySelectorAll('.pdf-search-highlight')) { + el.remove(); + } +} + +function highlightMatches(matches: SearchMatch[], currentIndex: number) { + // Group by page wrapper to batch DOM reads + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const span = match.span; + const pageWrapper = span.closest('[data-page-index]') as HTMLElement; + if (!pageWrapper) continue; + + // We need to measure where the matched substring is within the span. + // Use a Range to get the bounding rect of the matched characters. + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const range = document.createRange(); + try { + range.setStart(textNode, match.startOffset); + range.setEnd(textNode, match.startOffset + match.length); + } catch { + continue; + } + + const rects = range.getClientRects(); + const wrapperRect = pageWrapper.getBoundingClientRect(); + + for (const rect of rects) { + if (rect.width === 0 || rect.height === 0) continue; + const highlight = document.createElement('div'); + highlight.className = + i === currentIndex ? 'pdf-search-highlight pdf-search-highlight-current' : 'pdf-search-highlight'; + highlight.style.position = 'absolute'; + highlight.style.left = `${rect.left - wrapperRect.left}px`; + highlight.style.top = `${rect.top - wrapperRect.top}px`; + highlight.style.width = `${rect.width}px`; + highlight.style.height = `${rect.height}px`; + highlight.style.pointerEvents = 'none'; + if (i === currentIndex) { + highlight.setAttribute('data-current', 'true'); + } + pageWrapper.appendChild(highlight); + } + } +} + +export function PdfSearchBar({ containerRef, onClose, onNavigate }: PdfSearchBarProps) { + const [query, setQuery] = useState(''); + const [activeQuery, setActiveQuery] = useState(''); + const [matches, setMatches] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const inputRef = useRef(null); + + // Focus input on mount + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + // Auto-commit query when 2+ characters; clear when empty + useEffect(() => { + if (query.length >= 2) { + setActiveQuery(query); + } else if (query.length === 0) { + setActiveQuery(''); + } + }, [query]); + + // Find matches when activeQuery changes + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + if (!activeQuery) { + setMatches([]); + setCurrentIndex(0); + return; + } + + const found = findMatchesInTextLayers(container, activeQuery); + setMatches(found); + setCurrentIndex(0); + }, [activeQuery, containerRef]); + + // Highlight matches and scroll to current + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + clearHighlights(container); + if (matches.length === 0) return; + + highlightMatches(matches, currentIndex); + + const currentHighlight = container.querySelector('.pdf-search-highlight-current'); + if (currentHighlight) { + currentHighlight.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, [currentIndex, matches, containerRef]); + + const goToNext = useCallback(() => { + if (matches.length === 0) return; + setCurrentIndex((prev) => (prev + 1) % matches.length); + }, [matches.length]); + + const goToPrev = useCallback(() => { + if (matches.length === 0) return; + setCurrentIndex((prev) => (prev - 1 + matches.length) % matches.length); + }, [matches.length]); + + // Expose navigation handlers to parent for window-level Cmd+G + useEffect(() => { + onNavigate?.({ goToNext, goToPrev }); + }, [onNavigate, goToNext, goToPrev]); + + // Re-run search when text layers are rebuilt (e.g. after zoom commit) + useEffect(() => { + const container = containerRef.current; + if (!container || !activeQuery) return; + + const observer = new MutationObserver((mutations) => { + const hasChildListChange = mutations.some((m) => m.type === 'childList'); + if (!hasChildListChange) return; + const current = containerRef.current; + if (!current) return; + const found = findMatchesInTextLayers(current, activeQuery); + setMatches(found); + setCurrentIndex((prev) => Math.min(prev, Math.max(0, found.length - 1))); + }); + + observer.observe(container, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [activeQuery, containerRef]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } else if (event.key === 'Enter' && event.shiftKey) { + event.preventDefault(); + if (query.length === 1 && activeQuery !== query) { + setActiveQuery(query); + } else { + goToPrev(); + } + } else if (event.key === 'Enter') { + event.preventDefault(); + if (query.length === 1 && activeQuery !== query) { + setActiveQuery(query); + } else { + goToNext(); + } + } else if (event.key === 'g' && event.metaKey && event.shiftKey) { + event.preventDefault(); + goToPrev(); + } else if (event.key === 'g' && event.metaKey) { + event.preventDefault(); + goToNext(); + } + }, + [onClose, goToNext, goToPrev, query, activeQuery], + ); + + // Clean up highlights on unmount + useEffect(() => { + return () => { + const container = containerRef.current; + if (container) clearHighlights(container); + }; + }, [containerRef]); + + return ( +
+ setQuery(e.target.value)} + placeholder="Find in PDF..." + className="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400" + /> + {query && ( + + {matches.length > 0 ? `${currentIndex + 1} of ${matches.length}` : 'No matches'} + + )} +
+ + +
+ +
+ ); +} diff --git a/src/renderer/components/pdf/usePdfDocument.ts b/src/renderer/components/pdf/usePdfDocument.ts index a23b9da..e21cba6 100644 --- a/src/renderer/components/pdf/usePdfDocument.ts +++ b/src/renderer/components/pdf/usePdfDocument.ts @@ -1,6 +1,9 @@ import type { PDFDocumentProxy } from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist'; import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'; +// PDF.js appends CMap filenames to this URL prefix to fetch them on demand. +import cMapSample from 'pdfjs-dist/cmaps/78-H.bcmap?url'; +const cMapUrl = cMapSample.replace(/[^/]+$/, ''); import { useEffect, useRef, useState } from 'react'; pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl; @@ -53,7 +56,7 @@ export function usePdfDocument(paperId: string | null, pdfVersion: number, pdfUr try { const prevDoc = documentRef.current; - const doc = await pdfjsLib.getDocument({ data: copy }).promise; + const doc = await pdfjsLib.getDocument({ data: copy, cMapUrl, cMapPacked: true }).promise; if (cancelled) { doc.destroy(); return; diff --git a/src/renderer/stores/shortcutStore.ts b/src/renderer/stores/shortcutStore.ts index fc0b377..850912d 100644 --- a/src/renderer/stores/shortcutStore.ts +++ b/src/renderer/stores/shortcutStore.ts @@ -32,6 +32,7 @@ const DEFAULT_SHORTCUTS: Shortcut[] = [ { id: 'highlightSelection', label: 'Highlight Selection', keys: 'Meta+e' }, { id: 'importPdfs', label: 'Import PDFs', keys: 'Meta+i' }, { id: 'toggleMcp', label: 'Toggle MCP Server', keys: 'Meta+t' }, + { id: 'findInPdf', label: 'Find in PDF', keys: 'Meta+f' }, ]; function buildOverridesFromShortcuts(shortcuts: Shortcut[]): Record { diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 95071a4..de8823c 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -88,6 +88,29 @@ font-size: 12px; } +/* PDF text search highlights */ +.pdf-search-highlight { + background: rgba(255, 200, 0, 0.35); + border-radius: 1px; + mix-blend-mode: multiply; +} + +.pdf-search-highlight-current { + background: rgba(255, 150, 0, 0.6); +} + +@media (prefers-color-scheme: dark) { + .pdf-search-highlight { + background: rgba(255, 200, 0, 0.3); + mix-blend-mode: screen; + } + + .pdf-search-highlight-current { + background: rgba(255, 150, 0, 0.5); + mix-blend-mode: screen; + } +} + @keyframes toast-slide-up { from { opacity: 0;