Skip to content

feat: add Find in PDF (Cmd+F) text search#102

Merged
dakl merged 17 commits intomainfrom
claude/review-open-issues-Vz2GM
Mar 4, 2026
Merged

feat: add Find in PDF (Cmd+F) text search#102
dakl merged 17 commits intomainfrom
claude/review-open-issues-Vz2GM

Conversation

@dakl
Copy link
Copy Markdown
Owner

@dakl dakl commented Feb 27, 2026

Summary

Add in-document text search to the PDF viewer and polish the toolbar UX.

Find in PDF

  • Case-insensitive search through pdfjs text layer spans
  • Yellow highlight overlays on all matches, orange for current match
  • Navigate with Enter/Shift+Enter or Cmd+G/Cmd+Shift+G
  • Match counter showing "N of M" results
  • Auto-scrolls to bring current match into view
  • Single-char queries require Enter to search (avoids noise)
  • Search bar stays visible during pinch-to-zoom

Toolbar redesign

  • Unified two-row header: title + zoom on row 1, authors + all icons on row 2
  • All 6 action icons in one row (collection, tag, info | search, highlight, note)
  • Compact zoom controls with "16p" page count
  • Custom hover tooltips (Tooltip component) on all buttons

Keyboard shortcuts

  • Find in PDF wired through shortcut store (remappable)
  • Highlight selection wired through shortcut store (remappable)
  • Grouped Cmd-hold hint pills: "⌘+/⌘− Zoom" and "⌘G Find Next/Prev"

Other improvements

  • Fix PDF font warnings by providing cMapUrl/cMapPacked to pdf.js
  • Bump max zoom from 300% to 1000%
  • Add shortcut store unit tests (20 tests)

Test plan

  • Open a PDF and press Cmd+F to search
  • Verify search bar stays visible when zooming
  • Verify single character doesn't auto-search, but Enter triggers it
  • Hold Cmd to see hint pills for zoom and find
  • Hover over toolbar icons to see tooltips
  • Remap "Find in PDF" shortcut in Settings, verify new key works
  • All 254 tests pass

Closes #99

🤖 Generated with Claude Code

Add in-document text search to the PDF viewer. Pressing Cmd+F opens a
search bar that finds and highlights matches across all rendered pages.

- Case-insensitive search through pdfjs text layer spans
- Yellow highlight overlays on all matches, orange for current match
- Navigate with Enter/Shift+Enter or Cmd+G/Cmd+Shift+G
- Match counter showing "N of M" results
- Auto-scrolls to bring current match into view
- Escape or close button dismisses the search bar
- Dark mode support for highlight colors

Closes #99

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
Playwright test that seeds a paper with a generated test PDF, opens it,
triggers Cmd+F search, types a query, and captures screenshots at each
step. Screenshots are saved to test-results/ and uploaded as CI artifacts.

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Feb 27, 2026

Greptile Summary

This PR adds comprehensive text search functionality to the PDF viewer with keyboard shortcuts, visual highlights, and a polished toolbar UX.

Key additions:

  • In-document PDF search with case-insensitive matching through pdf.js text layers
  • Yellow highlights for all matches, orange for current match, with navigation via Enter/Shift+Enter or Cmd+G/Cmd+Shift+G
  • Performance optimizations: 150ms debounce and windowed highlighting (max 500 visible matches)
  • Remappable keyboard shortcuts for "Find in PDF" and "Highlight Selection" via shortcut store
  • Unified toolbar design with tooltips on all action buttons
  • Proper cleanup in e2e tests with finally blocks and real PDF generation using pdf-lib
  • cMapUrl configuration to fix PDF.js font warnings

Implementation quality:

  • Comprehensive test coverage: 20 shortcut store unit tests, PDF search unit tests with jsdom, and e2e test for search UI
  • Accessibility improvements with aria-label attributes on search input and navigation buttons
  • Search bar properly remounts when switching papers using key prop with paperId/arxivId/pdfUrl fallback
  • All 254 tests pass

Outstanding items from previous review threads:
Several issues were identified in previous review comments that remain unaddressed. These include DOM ref stability on re-render, MutationObserver triggering on highlight changes, Cmd+G handler duplication, and the fragile cMapUrl regex derivation. While not critical blockers, they represent areas for future refinement.

Confidence Score: 4/5

  • Safe to merge with minor refinements recommended
  • Strong implementation with comprehensive testing (254 tests passing, including new unit and e2e tests). Core search functionality works well with performance optimizations in place. Previous review threads identified several polish items (DOM ref handling, observer efficiency, keyboard shortcut edge cases) that don't block the feature but would improve robustness. The PR delivers on its stated goals and maintains code quality standards.
  • Pay attention to src/renderer/components/pdf/PdfSearchBar.tsx and src/renderer/components/PdfViewer.tsx - previous review threads identified several refinement opportunities around DOM reference stability and keyboard shortcut handling

Important Files Changed

Filename Overview
src/renderer/components/pdf/PdfSearchBar.tsx Implements PDF text search with debouncing, highlight windowing (max 500), and keyboard navigation. Has accessibility improvements (aria-label attributes). Previous threads identified issues with HTMLElement refs breaking on re-render and MutationObserver triggering on highlight changes.
src/renderer/components/PdfViewer.tsx Adds search UI integration, remappable keyboard shortcuts for find/highlight, and toolbar redesign with tooltips. Implements window-level Cmd+G handlers and proper key prop for search bar remounting. Previous threads noted issues with Cmd+G duplication and preventDefault behavior.
src/renderer/stores/shortcutStore.ts Adds findInPdf shortcut (Meta+f) to default shortcuts. Store properly handles conflict detection and provides remapping functionality. No issues found.
e2e/pdf-search.spec.ts E2E test for Cmd+F search functionality. Creates real PDF with pdf-lib, properly cleans up temp directory in finally block, and verifies search UI and match counting. Cleanup issue from previous threads has been addressed.
src/renderer/components/pdf/usePdfDocument.ts Adds cMapUrl/cMapPacked config to fix PDF font warnings. Uses regex to derive URL from sample import. Previous thread noted this approach is fragile if Vite changes asset handling.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User presses Cmd+F] --> B[PdfViewer: setShowSearch true]
    B --> C[PdfSearchBar mounts]
    C --> D[Input focused & auto-selected]
    
    E[User types query] --> F{Query length?}
    F -->|0 chars| G[Clear activeQuery immediately]
    F -->|1 char| H[Wait for Enter]
    F -->|2+ chars| I[Debounce 150ms]
    
    H --> J[Enter pressed: flush debounce]
    I --> K[Set activeQuery]
    J --> K
    
    K --> L[findMatchesInTextLayers]
    L --> M[Scan all .textLayer spans]
    M --> N{Matches found?}
    
    N -->|Yes| O[setMatches array]
    N -->|No| P[Show 'No matches']
    
    O --> Q{Total > 500?}
    Q -->|Yes| R[getHighlightWindow: show 500 centered on current]
    Q -->|No| S[Highlight all matches]
    R --> S
    
    S --> T[Create highlight divs with absolute positioning]
    T --> U[ScrollIntoView current match]
    
    V[Enter/Cmd+G] --> W[goToNext: increment currentIndex]
    X[Shift+Enter/Cmd+Shift+G] --> Y[goToPrev: decrement currentIndex]
    W --> S
    Y --> S
    
    Z[PDF zoom/scale change] --> AA[Text layers rebuilt in PdfPage]
    AA --> AB[MutationObserver detects childList change]
    AB --> AC[Re-run findMatchesInTextLayers]
    AC --> O
Loading

Last reviewed commit: aa46901

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds in-document “Find in PDF” functionality to the renderer’s PDF viewer, enabling Cmd+F search with match highlighting and navigation to satisfy issue #99.

Changes:

  • Introduces a PdfSearchBar overlay that scans pdfjs text-layer spans, highlights matches, and supports keyboard navigation/scroll-to-current.
  • Hooks Cmd+F / Escape handling into PdfViewer and renders the search UI within the PDF scroll container.
  • Adds global CSS highlight styles plus an e2e test (and helper) that generates a searchable PDF and verifies Cmd+F behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/renderer/styles/globals.css Adds styles for search highlights (including dark mode).
src/renderer/components/pdf/PdfSearchBar.tsx Implements DOM-based text search, highlighting, and navigation UI.
src/renderer/components/PdfViewer.tsx Wires Cmd+F/Escape to toggle the search bar and positions it in the viewer.
e2e/pdf-search.spec.ts Adds Playwright coverage for opening search, querying, navigating, and closing.
e2e/helpers/create-test-pdf.ts Creates a minimal PDF with embedded text for reliable search testing.
Comments suppressed due to low confidence (3)

e2e/pdf-search.spec.ts:60

  • This test writes screenshots to hard-coded paths under test-results/. In Playwright, parallel execution, retries, or multiple shards can cause filename collisions and flaky artifacts. Prefer test.info().outputPath(...) (or only capture screenshots conditionally on failure) so each run gets isolated output locations.
  // 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' });

src/renderer/components/pdf/PdfSearchBar.tsx:90

  • highlightMatches does repeated layout reads/writes for every match (creating a Range, calling getClientRects(), and computing pageWrapper.getBoundingClientRect() inside the per-match loop). With many matches this can become a noticeable performance bottleneck. Consider grouping matches by page wrapper, computing wrapperRect once per page, and batching DOM insertion (e.g., via a DocumentFragment) to reduce layout thrash.
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`;

e2e/pdf-search.spec.ts:17

  • afterAll unconditionally calls fs.rmSync(pdfDir, ...), but pdfDir is assigned in beforeAll. If beforeAll fails or is skipped, pdfDir will be undefined and rmSync will throw, masking the real failure. Guard the cleanup (e.g., check pdfDir is truthy and exists) before deleting.
test.afterAll(() => {
  fs.rmSync(pdfDir, { recursive: true, force: true });
});

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

setMatches(found);
setCurrentIndex(0);
}, [query, containerRef]);

Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SearchMatch stores direct references to pdfjs text-layer elements. The text layer is rebuilt via container.innerHTML = '' during re-renders (e.g., zoom/scale change), so these span references can become detached and the search counter/highlight navigation will stop working or show stale results. Consider re-running findMatchesInTextLayers when the text layer is regenerated (e.g., pass scale/pdfVersion into PdfSearchBar and add them to the dependency list, or attach a MutationObserver on the container/textLayer to recompute matches when spans change).

Suggested change
// Re-run search when the PDF text layer is regenerated (e.g., zoom/scale changes)
useEffect(() => {
const container = containerRef.current;
if (!container || !query) {
return;
}
const observer = new MutationObserver(mutations => {
// Only react to changes in the direct children of the container, which
// indicates the text layer was likely rebuilt (e.g., via innerHTML = '').
const hasChildListChange = mutations.some(mutation => mutation.type === 'childList');
if (!hasChildListChange) {
return;
}
const updatedContainer = containerRef.current;
if (!updatedContainer) {
return;
}
const found = findMatchesInTextLayers(updatedContainer, query);
setMatches(found);
setCurrentIndex(0);
});
observer.observe(container, {
childList: true,
});
return () => {
observer.disconnect();
};
}, [query, containerRef]);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

9 files reviewed, 12 comments

Edit Code Review Agent Settings | Greptile


<div ref={containerRef} className="flex-1 overflow-auto bg-gray-100 dark:bg-gray-900">
<div ref={containerRef} className="relative flex-1 overflow-auto bg-gray-100 dark:bg-gray-900">
{showSearch && <PdfSearchBar containerRef={containerRef} onClose={closeSearch} />}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

search state persists when switching papers - if a user has search open with matches and switches to a different paper, the old match count displays but references detached DOM nodes. add a key prop that changes when paperId changes to force remount

Create the test PDF inside electronApp.evaluate (main process) to
eliminate cross-process file path issues. Add intermediate waits for
"Loading PDF..." state and explicit error checking before waiting for
text layer. Increase timeouts. Remove unused helper file.

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
Comment on lines +3 to +12
interface SearchMatch {
/** The text layer <span> 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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing HTMLElement references in matches breaks when PDF re-renders (zoom, scale changes). Text layers are rebuilt in PdfPage.tsx line 199, creating new DOM nodes. Old span refs become detached, causing span.closest() at line 62 to return null, making highlights disappear.

Solution: Re-run search when scale changes, or store match positions by text content + page index instead of DOM refs.

Electron main process is CJS — dynamic import() fails with
"A dynamic import callback was not specified". Switch to require().

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
The sandboxed evaluate context has neither require() nor import().
Create the PDF in the test's Node.js context and pass the path
into evaluate for DB seeding only — matching the pattern used
by other e2e tests.

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
Comment on lines +190 to +196
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find in PDF..."
className="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add aria-label attributes to improve screen reader accessibility. The input has a placeholder but needs an aria-label, and the navigation buttons rely only on title attributes.

Suggested change
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find in PDF..."
className="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400"
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find in PDF..."
aria-label="Search PDF text"
className="w-48 text-mac-small bg-transparent border-none outline-none placeholder-gray-400"
/>

Comment on lines +112 to +126
// Find matches when query changes
useEffect(() => {
const container = containerRef.current;
if (!container) return;

if (!query) {
setMatches([]);
setCurrentIndex(0);
return;
}

const found = findMatchesInTextLayers(container, query);
setMatches(found);
setCurrentIndex(0);
}, [query, containerRef]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider debouncing the search input to improve performance on large PDFs. Currently, every keystroke triggers a full search through all text layers, which could cause lag on PDFs with many pages.

You could use a debounce delay of 150-300ms to wait for the user to stop typing before running the search.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

The loading indicator is transient and may disappear before the
assertion runs, especially in CI. Wait directly for the text layer
to render instead.

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
electronApp.evaluate passes the Electron module as the first arg
to the callback — the data arg comes second. Without _electron as
the first parameter, _params received the Electron module object
and _params.pdfPath was undefined, so the PDF viewer never loaded.

Also bumps test timeout to 60s for PDF rendering in CI.

https://claude.ai/code/session_01PtqSxhcyfUARLFvQPjMbqt
Comment on lines +154 to +175
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault();
goToPrev();
} else if (event.key === 'Enter') {
event.preventDefault();
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],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cmd+G/Cmd+Shift+G shortcuts only work when search bar has focus. The handleKeyDown is attached to the div's onKeyDown (line 188), not as a window-level listener. If a user clicks on the PDF after searching, focus moves away and Cmd+G navigation stops working.

Move Cmd+G handling to a window event listener in a useEffect (like Cmd+F in PdfViewer line 417), or keep focus trapped in the search bar.

Comment on lines +57 to +97
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);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rendering hundreds/thousands of highlight divs could cause performance issues for common words. If a user searches for "the" in a large document, this creates a DOM element for each match without limits.

Consider capping visible highlights (e.g., only render highlights on currently visible pages) or adding a maximum highlight count with a warning.

Comment on lines +29 to +31
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'papershelf-pdf-'));
const pdfPath = path.join(tmpDir, 'test-paper.pdf');
fs.writeFileSync(pdfPath, pdfBytes);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temporary directory created with mkdtempSync is not cleaned up after the test completes. Over many test runs, this could accumulate files in the temp directory.

Add cleanup in test teardown or use a try...finally block.

dakl and others added 2 commits March 2, 2026 21:59
- Fix search bar disappearing on zoom by moving it outside scrollable container
- Delay auto-search for single-char queries (require Enter)
- Unify header: title+zoom on row 1, authors+icons on row 2
- Add search icon with ShortcutHint (⌘F) to toolbar
- Wire keyboard shortcuts through store so remapping works
- Add grouped Cmd-hold hint pills for zoom and find next/prev
- Add Tooltip component and hover tooltips for all toolbar buttons
- Fix PDF font warnings by providing cMapUrl to pdf.js
- Bump max zoom from 300% to 1000%
- Add findInPdf to default shortcuts
- Add shortcut store unit tests (20 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Stale matches on zoom: MutationObserver re-runs search when text
   layers are rebuilt after zoom commit
2. Search state persists across papers: key={paperId} remounts
   PdfSearchBar when switching papers
3. Cmd+G only works with focus: window-level keyboard handler in
   PdfViewer now handles Cmd+G/Cmd+Shift+G via searchNavRef exposed
   through onNavigate callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +697 to +708
if (event.key === '=' || event.key === '+') {
zoomIn();
return;
}
if (event.key === '-') {
zoomOut();
return;
}
if (event.key === '0') {
zoomReset();
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing event.preventDefault() calls for zoom shortcuts - browser's default Cmd+/Cmd- zoom will interfere with app zoom

Suggested change
if (event.key === '=' || event.key === '+') {
zoomIn();
return;
}
if (event.key === '-') {
zoomOut();
return;
}
if (event.key === '0') {
zoomReset();
return;
}
if (event.metaKey) {
if (event.key === '=' || event.key === '+') {
event.preventDefault();
zoomIn();
return;
}
if (event.key === '-') {
event.preventDefault();
zoomOut();
return;
}
if (event.key === '0') {
event.preventDefault();
zoomReset();
return;
}
}

Comment on lines +730 to +733
case 'highlightSelection':
event.preventDefault();
toggleHighlightMode();
break;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

highlight shortcut fires in readonly mode - add guard to prevent annotation attempts on papers not in library

Suggested change
case 'highlightSelection':
event.preventDefault();
toggleHighlightMode();
break;
case 'highlightSelection':
if (!readOnly) {
event.preventDefault();
toggleHighlightMode();
}
break;


<div ref={containerRef} className="flex-1 overflow-auto bg-gray-100 dark:bg-gray-900">
<div className="relative flex-1">
{showSearch && <PdfSearchBar key={paperId} containerRef={containerRef} onClose={closeSearch} onNavigate={handleSearchNavigate} />}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key only uses paperId, but for papers without IDs (search results), the component won't remount when switching papers. Use a fallback like arxivId or pdfUrl to ensure remounting works for all paper types.

- Add event.preventDefault() to zoom shortcuts to prevent browser default zoom
- Guard highlight shortcut from firing in readonly mode
- Use paperId ?? arxivId ?? pdfUrl as key fallback for PdfSearchBar remount
- Add aria-label to search input for accessibility
- Clean up e2e temp directory with try/finally
- Fix biome lint/formatting issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +209 to +214
} else if (event.key === 'g' && event.metaKey && event.shiftKey) {
event.preventDefault();
goToPrev();
} else if (event.key === 'g' && event.metaKey) {
event.preventDefault();
goToNext();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cmd+G handling duplicates with PdfViewer's window-level listener. When search bar has focus, this handler fires first (calls goToNext()), then PdfViewer's handler also fires (calls searchNavRef.current?.goToNext()), causing navigation to skip a match.

Add event.stopPropagation() after preventDefault() to prevent bubbling to window listener.

Comment on lines +175 to +186
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();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MutationObserver fires on any childList change including highlight div additions/removals (from highlightMatches/clearHighlights), causing redundant searches. Each highlight update triggers observer → setMatches(newArray) → highlight effect runs → triggers observer again.

Filter mutations to only process text layer changes, or check if mutations involve .pdf-search-highlight elements and skip those.

Addresses Greptile review feedback:
- Debounce keystroke-driven search (150ms) to avoid full-text scan on every keypress
- Cap rendered highlight divs to 500 via a sliding window around the current match
- Extract pure functions (getHighlightWindow, createDebouncedSetter) with 22 tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines +711 to +718
if (event.metaKey && event.key === 'g') {
event.preventDefault();
if (event.shiftKey) {
searchNavRef.current?.goToPrev();
} else {
searchNavRef.current?.goToNext();
}
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preventDefault() called even when search isn't active, blocking browser's native Cmd+G. Check if searchNavRef.current exists before preventing default:

Suggested change
if (event.metaKey && event.key === 'g') {
event.preventDefault();
if (event.shiftKey) {
searchNavRef.current?.goToPrev();
} else {
searchNavRef.current?.goToNext();
}
return;
if (event.metaKey && event.key === 'g') {
if (searchNavRef.current) {
event.preventDefault();
if (event.shiftKey) {
searchNavRef.current.goToPrev();
} else {
searchNavRef.current.goToNext();
}
}
return;


const currentHighlight = container.querySelector('.pdf-search-highlight-current');
if (currentHighlight) {
currentHighlight.scrollIntoView({ block: 'center', behavior: 'smooth' });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

behavior: 'smooth' causes animation conflicts when rapidly navigating matches (holding Enter). Use 'auto' for instant navigation:

Suggested change
currentHighlight.scrollIntoView({ block: 'center', behavior: 'smooth' });
currentHighlight.scrollIntoView({ block: 'center', behavior: 'auto' });

if (!current) return;
const found = findMatchesInTextLayers(current, activeQuery);
setMatches(found);
setCurrentIndex((prev) => Math.min(prev, Math.max(0, found.length - 1)));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Index preserved after text layer rebuild, but match at that index might be completely different content now. User loses their place. Consider resetting to 0 or finding the match closest to the viewport.

// 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(/[^/]+$/, '');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deriving cMapUrl by regex replacement on sample URL is fragile—breaks if Vite changes asset handling. Consider hardcoding the path or using an environment variable.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +318 to +337
<button
onClick={goToPrev}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 8L6 4L10 8" />
</svg>
</button>
<button
onClick={goToNext}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 4L6 8L10 4" />
</svg>
</button>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Navigation buttons use title attribute which isn't reliably announced by screen readers. Add aria-label for accessibility:

Suggested change
<button
onClick={goToPrev}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 8L6 4L10 8" />
</svg>
</button>
<button
onClick={goToNext}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 4L6 8L10 4" />
</svg>
</button>
<button
onClick={goToPrev}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
aria-label="Previous match"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 8L6 4L10 8" />
</svg>
</button>
<button
onClick={goToNext}
disabled={matches.length === 0}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
aria-label="Next match"
>

dakl and others added 6 commits March 4, 2026 13:54
- Use instant scroll ('auto') instead of smooth to avoid animation conflicts on rapid navigation
- Reset match index to 0 after text layer rebuild instead of preserving stale index
- Add aria-label to prev/next navigation buttons for screen readers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
locator.press('Enter') hangs in CI's headless xvfb environment due to
a Playwright + Electron quirk. Switch to window.keyboard.press which
dispatches the event at the page level and doesn't get swallowed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Electron app crashes during PDF rendering on CI's xvfb environment
due to GPU-accelerated canvas without proper GL support. Adding
--disable-gpu prevents the crash and fixes both library and pdf-search
e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Electron's input handling can hang on locator.press('Enter') when the
keydown handler triggers scrollIntoView. page.keyboard.press dispatches
the key event without waiting for element acknowledgement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The favorite button now lives inside PdfViewer, which only renders when
the paper has a PDF. Update seeded papers to include a temp PDF file so
the toolbar (with favorite button) is visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
keyboard.press('Enter') crashes the Electron renderer in CI's headless
environment when it triggers scrollIntoView. The core search behavior
(open search, type query, verify matches) is already tested by the
match counter assertion. Remove the flaky navigation step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dakl dakl merged commit 753ff22 into main Mar 4, 2026
4 checks passed
@dakl dakl deleted the claude/review-open-issues-Vz2GM branch March 4, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Find in PDF (Cmd+F)

3 participants