Add PDF search feature to pdf-server (#445)

* Add PDF search feature to pdf-server

Adds full-text search functionality to the PDF viewer:

- Search button in toolbar (Ctrl/Cmd+F to open)
- Search bar with input, prev/next navigation, match count
- Text extraction and caching across all pages
- Highlight overlays using DOM Range API
- Case-insensitive search across entire document
- Keyboard shortcuts: Ctrl+F (open), Enter (next), Shift+Enter (prev), Escape (close)

Changes:
- mcp-app.html: Add search button, search bar, highlight layer
- src/mcp-app.css: Add search/highlight styles and PDF.js text layer rules
- src/mcp-app.ts: Add search state, functions, event listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* update screenshots

* Add fullscreen button to threejs-server

* Fix prettier formatting

* Move search bar below toolbar, right-aligned

* Make search bar compact and right-aligned

* Float search bar over page content

* Ctrl/Cmd+F toggles search; pass through to browser when empty

* Hide search nav controls until query is entered

* Skip scroll when search highlight is already visible

* Fix search bar right-edge alignment with container border

* Prevent browser find when our search is open with text

* Cmd+F when search input is focused passes through to browser find

* Fix prettier formatting in README

* Remove unrelated formatting and threejs changes from PR

* prettier

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2026-02-19 10:56:40 -08:00 • committed by GitHub
commit 8cad9f04c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -13,7 +13,10 @@
--text200: light-dark(#999999, #888888);
/* Shadows */
--shadow-page: light-dark(0 2px 8px rgba(0, 0, 0, 0.15), 0 2px 8px rgba(0, 0, 0, 0.4));
--shadow-page: light-dark(
0 2px 8px rgba(0, 0, 0, 0.15),
0 2px 8px rgba(0, 0, 0, 0.4)
);
--selection-bg: light-dark(rgba(0, 0, 255, 0.3), rgba(100, 150, 255, 0.4));
}
@ -114,6 +117,7 @@ body {
flex-direction: column;
flex: 1;
overflow: visible;
position: relative;
}
/* Toolbar */
@ -256,7 +260,10 @@ body {
display: block;
}
/* Text Layer for Selection */
/* Text Layer for Selection
* Critical: must include font-size and transform rules that PDF.js TextLayer
* relies on via CSS custom properties (--font-height, --scale-x, --rotate).
* Without these, text layer spans won't align with the canvas rendering. */
.text-layer {
position: absolute;
left: 0;
@ -270,10 +277,11 @@ body {
forced-color-adjust: none;
transform-origin: 0 0;
z-index: 2;
/* PDF.js TextLayer sets --min-font-size on container */
--min-font-size-inv: calc(1 / var(--min-font-size, 1));
}
.text-layer span,
.text-layer br {
.text-layer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
@ -281,6 +289,23 @@ body {
transform-origin: 0% 0%;
}
/* PDF.js sets --font-height, --scale-x, --rotate as inline styles on each span.
* These rules apply proper font size and transforms to match the canvas. */
.text-layer > :not(.markedContent),
.text-layer .markedContent span:not(.markedContent) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--scale-factor, 1) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
scale(var(--min-font-size-inv, 1));
}
.text-layer .markedContent {
display: contents;
}
.text-layer ::selection {
background: var(--selection-bg);
}
@ -310,3 +335,127 @@ body {
min-height: 0; /* Allow flex item to shrink below content size */
overflow: auto; /* Scroll within the document area only */
}
/* Search Button */
.search-btn,
.nav-btn,
.zoom-btn,
.fullscreen-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--bg200);
border-radius: 4px;
background: var(--bg000);
color: var(--text000);
cursor: pointer;
font-size: 1rem;
transition: all 0.15s ease;
}
.search-btn:hover,
.nav-btn:hover:not(:disabled),
.zoom-btn:hover:not(:disabled),
.fullscreen-btn:hover {
background: var(--bg100);
border-color: var(--bg300);
}
/* Search Bar */
.search-bar {
display: flex;
align-items: center;
position: absolute;
top: 47px; /* below toolbar (48px - 1px border overlap) */
right: -1px; /* align with outer border */
padding: 0.375rem 0.5rem;
background: var(--bg000);
border: 1px solid var(--bg200);
border-top: none;
border-radius: 0 0 6px 6px;
gap: 0.5rem;
z-index: 10;
}
.search-input {
width: 200px;
padding: 0.25rem 0.5rem;
border: 1px solid var(--bg200);
border-radius: 4px;
font-size: 0.85rem;
background: var(--bg000);
color: var(--text000);
}
.search-input:focus {
outline: none;
border-color: var(--text100);
}
.search-match-count {
font-size: 0.8rem;
color: var(--text100);
white-space: nowrap;
min-width: 60px;
}
.search-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--bg200);
border-radius: 4px;
background: var(--bg000);
color: var(--text000);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s ease;
}
.search-nav-btn:hover:not(:disabled) {
background: var(--bg100);
border-color: var(--bg300);
}
.search-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Highlight Layer */
.highlight-layer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 1;
}
.search-highlight {
background: rgba(255, 255, 0, 0.4);
mix-blend-mode: multiply;
border-radius: 2px;
pointer-events: none;
}
.search-highlight.current {
background: rgba(255, 165, 0, 0.6);
}
@media (prefers-color-scheme: dark) {
.search-highlight {
background: rgba(255, 255, 0, 0.3);
mix-blend-mode: screen;
}
.search-highlight.current {
background: rgba(255, 165, 0, 0.5);
mix-blend-mode: screen;
}
}