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
4 changed files with 556 additions and 15 deletions

View file

@ -84,12 +84,12 @@ npm install -S @modelcontextprotocol/ext-apps
The SDK serves three roles: app developers building interactive Views, host developers embedding those Views, and MCP server authors registering tools with UI metadata.
| Package | Purpose | Docs |
|---------|---------|------|
| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) |
| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) |
| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) |
| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/server.html) |
| Package | Purpose | Docs |
| ------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `@modelcontextprotocol/ext-apps` | Build interactive Views (App class, PostMessageTransport) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app.html) |
| `@modelcontextprotocol/ext-apps/react` | React hooks for Views (useApp, useHostStyles, etc.) | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/_modelcontextprotocol_ext-apps_react.html) |
| `@modelcontextprotocol/ext-apps/app-bridge` | Embed and communicate with Views in your chat client | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/app-bridge.html) |
| `@modelcontextprotocol/ext-apps/server` | Register tools and resources on your MCP server | [API Docs →](https://modelcontextprotocol.github.io/ext-apps/api/modules/server.html) |
There's no _supported_ host implementation in this repo (beyond the [examples/basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example).

View file

@ -11,7 +11,11 @@
<div id="loading" class="loading">
<div class="spinner"></div>
<p id="loading-text">Loading PDF...</p>
<div id="progress-container" class="progress-container" style="display: none">
<div
id="progress-container"
class="progress-container"
style="display: none"
>
<div id="progress-bar" class="progress-bar"></div>
</div>
<p id="progress-text" class="progress-text"></p>
@ -57,16 +61,58 @@
<button id="zoom-in-btn" class="zoom-btn" title="Zoom in (+)">
+
</button>
<button id="fullscreen-btn" class="fullscreen-btn" title="Toggle fullscreen">
<button
id="search-btn"
class="search-btn"
title="Search (Ctrl+F)"
></button>
<button
id="fullscreen-btn"
class="fullscreen-btn"
title="Toggle fullscreen"
>
⛶
</button>
</div>
</div>
<!-- Search bar below toolbar, right-aligned -->
<div id="search-bar" class="search-bar" style="display: none">
<input
id="search-input"
class="search-input"
type="text"
placeholder="Search..."
autocomplete="off"
/>
<span id="search-match-count" class="search-match-count"></span>
<button
id="search-prev-btn"
class="search-nav-btn"
title="Previous (Shift+Enter)"
>
&#x25B2;
</button>
<button
id="search-next-btn"
class="search-nav-btn"
title="Next (Enter)"
>
&#x25BC;
</button>
<button
id="search-close-btn"
class="search-nav-btn"
title="Close (Esc)"
>
&#x2715;
</button>
</div>
<!-- Single Page Canvas Container -->
<div class="canvas-container">
<div class="page-wrapper">
<canvas id="pdf-canvas"></canvas>
<div id="highlight-layer" class="highlight-layer"></div>
<div id="text-layer" class="text-layer"></div>
</div>
</div>

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;
}
}

View file

@ -69,6 +69,38 @@ const fullscreenBtn = document.getElementById(
const progressContainerEl = document.getElementById("progress-container")!;
const progressBarEl = document.getElementById("progress-bar")!;
const progressTextEl = document.getElementById("progress-text")!;
const searchBtn = document.getElementById("search-btn") as HTMLButtonElement;
searchBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="6.5" cy="6.5" r="4.5"/><line x1="10" y1="10" x2="14" y2="14"/></svg>`;
const searchBarEl = document.getElementById("search-bar")!;
const searchInputEl = document.getElementById(
"search-input",
) as HTMLInputElement;
const searchMatchCountEl = document.getElementById("search-match-count")!;
const searchPrevBtn = document.getElementById(
"search-prev-btn",
) as HTMLButtonElement;
const searchNextBtn = document.getElementById(
"search-next-btn",
) as HTMLButtonElement;
const searchCloseBtn = document.getElementById(
"search-close-btn",
) as HTMLButtonElement;
const highlightLayerEl = document.getElementById("highlight-layer")!;
// Search state
interface SearchMatch {
pageNum: number;
index: number;
length: number;
}
let searchOpen = false;
let searchQuery = "";
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const pageTextCache = new Map<number, string>();
const pageTextItemsCache = new Map<number, string[]>();
let allMatches: SearchMatch[] = [];
let currentMatchIndex = -1;
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
@ -106,16 +138,262 @@ function requestFitToContent() {
const paddingBottom = parseFloat(containerStyle.paddingBottom);
// Calculate required height:
// toolbar + padding-top + page-wrapper height + padding-bottom + buffer
// toolbar + search-bar + padding-top + page-wrapper height + padding-bottom + buffer
const toolbarHeight = toolbarEl.offsetHeight;
const searchBarHeight = searchOpen ? searchBarEl.offsetHeight : 0;
const pageWrapperHeight = pageWrapperEl.offsetHeight;
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
const totalHeight =
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
toolbarHeight +
searchBarHeight +
paddingTop +
pageWrapperHeight +
paddingBottom +
BUFFER;
app.sendSizeChanged({ height: totalHeight });
}
// --- Search Functions ---
async function extractAllPageText() {
if (!pdfDocument) return;
for (let i = 1; i <= totalPages; i++) {
if (pageTextCache.has(i)) continue;
try {
const page = await pdfDocument.getPage(i);
const textContent = await page.getTextContent();
const items = (textContent.items as Array<{ str?: string }>).map(
(item) => item.str || "",
);
pageTextItemsCache.set(i, items);
pageTextCache.set(i, items.join(""));
} catch (err) {
log.error("Error extracting text for page", i, err);
}
}
}
function performSearch(query: string) {
allMatches = [];
currentMatchIndex = -1;
searchQuery = query;
if (!query) {
updateSearchUI();
clearHighlights();
return;
}
const lowerQuery = query.toLowerCase();
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
const pageText = pageTextCache.get(pageNum);
if (!pageText) continue;
const lowerText = pageText.toLowerCase();
let startIdx = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, startIdx);
if (idx === -1) break;
allMatches.push({ pageNum, index: idx, length: query.length });
startIdx = idx + 1;
}
}
// Set current match to first match on or after current page
if (allMatches.length > 0) {
const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
currentMatchIndex = idx >= 0 ? idx : 0;
}
updateSearchUI();
renderHighlights();
// Navigate to match page if needed
if (allMatches.length > 0 && currentMatchIndex >= 0) {
const match = allMatches[currentMatchIndex];
if (match.pageNum !== currentPage) {
goToPage(match.pageNum);
}
}
}
function renderHighlights() {
clearHighlights();
if (!searchQuery || allMatches.length === 0) return;
const spans = Array.from(
textLayerEl.querySelectorAll("span"),
) as HTMLElement[];
if (spans.length === 0) return;
const pageMatches = allMatches.filter((m) => m.pageNum === currentPage);
if (pageMatches.length === 0) return;
const lowerQuery = searchQuery.toLowerCase();
const lowerQueryLen = lowerQuery.length;
// Position highlight divs over matching text using Range API.
const wrapperEl = textLayerEl.parentElement!;
const wrapperRect = wrapperEl.getBoundingClientRect();
let domMatchOrdinal = 0;
for (const span of spans) {
const text = span.textContent || "";
if (text.length === 0) continue;
const lowerText = text.toLowerCase();
if (!lowerText.includes(lowerQuery)) continue;
// Find all match positions within this span
const matchPositions: number[] = [];
let pos = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, pos);
if (idx === -1) break;
matchPositions.push(idx);
pos = idx + 1;
}
if (matchPositions.length === 0) continue;
const textNode = span.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
for (const idx of matchPositions) {
const isCurrentMatch =
domMatchOrdinal < pageMatches.length &&
allMatches.indexOf(pageMatches[domMatchOrdinal]) === currentMatchIndex;
try {
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, Math.min(idx + lowerQueryLen, text.length));
const rects = range.getClientRects();
for (let ri = 0; ri < rects.length; ri++) {
const r = rects[ri];
const div = document.createElement("div");
div.className =
"search-highlight" + (isCurrentMatch ? " current" : "");
div.style.position = "absolute";
div.style.left = `${r.left - wrapperRect.left}px`;
div.style.top = `${r.top - wrapperRect.top}px`;
div.style.width = `${r.width}px`;
div.style.height = `${r.height}px`;
highlightLayerEl.appendChild(div);
}
} catch {
// Range errors can happen with stale text nodes
}
domMatchOrdinal++;
}
}
// Scroll current highlight into view only if not already visible
const currentHL = highlightLayerEl.querySelector(
".search-highlight.current",
) as HTMLElement;
if (currentHL) {
const scrollParent =
currentDisplayMode === "fullscreen"
? document.querySelector(".canvas-container")
: null;
if (scrollParent) {
const sr = scrollParent.getBoundingClientRect();
const hr = currentHL.getBoundingClientRect();
if (hr.top < sr.top || hr.bottom > sr.bottom) {
currentHL.scrollIntoView({ block: "center", behavior: "smooth" });
}
} else {
// Inline mode: check visibility in viewport
const hr = currentHL.getBoundingClientRect();
if (hr.top < 0 || hr.bottom > window.innerHeight) {
currentHL.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
}
}
function clearHighlights() {
highlightLayerEl.innerHTML = "";
}
function updateSearchUI() {
const hasQuery = searchQuery.length > 0;
if (allMatches.length === 0) {
searchMatchCountEl.textContent = hasQuery ? "No matches" : "";
} else {
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}`;
}
searchPrevBtn.disabled = allMatches.length === 0;
searchNextBtn.disabled = allMatches.length === 0;
// Hide nav controls when there's no query
const vis = hasQuery ? "" : "none";
searchMatchCountEl.style.display = vis;
searchPrevBtn.style.display = vis;
searchNextBtn.style.display = vis;
}
function openSearch() {
if (searchOpen) {
searchInputEl.focus();
searchInputEl.select();
return;
}
searchOpen = true;
searchBarEl.style.display = "flex";
updateSearchUI();
searchInputEl.focus();
requestFitToContent();
extractAllPageText();
}
function closeSearch() {
if (!searchOpen) return;
searchOpen = false;
searchBarEl.style.display = "none";
searchQuery = "";
searchInputEl.value = "";
allMatches = [];
currentMatchIndex = -1;
clearHighlights();
updateSearchUI();
requestFitToContent();
}
function toggleSearch() {
if (searchOpen) {
closeSearch();
} else {
openSearch();
}
}
function goToNextMatch() {
if (allMatches.length === 0) return;
currentMatchIndex = (currentMatchIndex + 1) % allMatches.length;
const match = allMatches[currentMatchIndex];
updateSearchUI();
if (match.pageNum !== currentPage) {
goToPage(match.pageNum);
} else {
renderHighlights();
}
}
function goToPrevMatch() {
if (allMatches.length === 0) return;
currentMatchIndex =
(currentMatchIndex - 1 + allMatches.length) % allMatches.length;
const match = allMatches[currentMatchIndex];
updateSearchUI();
if (match.pageNum !== currentPage) {
goToPage(match.pageNum);
} else {
renderHighlights();
}
}
// Create app instance
// autoResize disabled - app fills its container, doesn't request size changes
const app = new App(
@ -391,6 +669,8 @@ async function renderPage() {
textLayerEl.innerHTML = "";
textLayerEl.style.width = `${viewport.width}px`;
textLayerEl.style.height = `${viewport.height}px`;
// Set --scale-factor so CSS font-size/transform rules work correctly.
textLayerEl.style.setProperty("--scale-factor", `${scale}`);
// Render canvas - track the task so we can cancel it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -430,6 +710,24 @@ async function renderPage() {
});
await textLayer.render();
// Cache page text items if not already cached
if (!pageTextItemsCache.has(pageToRender)) {
const items = (textContent.items as Array<{ str?: string }>).map(
(item) => item.str || "",
);
pageTextItemsCache.set(pageToRender, items);
pageTextCache.set(pageToRender, items.join(""));
}
// Size highlight layer to match canvas
highlightLayerEl.style.width = `${viewport.width}px`;
highlightLayerEl.style.height = `${viewport.height}px`;
// Re-render search highlights if search is active
if (searchOpen && searchQuery) {
renderHighlights();
}
updateControls();
updatePageContext();
@ -549,8 +847,34 @@ prevBtn.addEventListener("click", prevPage);
nextBtn.addEventListener("click", nextPage);
zoomOutBtn.addEventListener("click", zoomOut);
zoomInBtn.addEventListener("click", zoomIn);
searchBtn.addEventListener("click", toggleSearch);
searchCloseBtn.addEventListener("click", closeSearch);
searchPrevBtn.addEventListener("click", goToPrevMatch);
searchNextBtn.addEventListener("click", goToNextMatch);
fullscreenBtn.addEventListener("click", toggleFullscreen);
// Search input events
searchInputEl.addEventListener("input", () => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
performSearch(searchInputEl.value);
}, 300);
});
searchInputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
goToPrevMatch();
} else {
goToNextMatch();
}
} else if (e.key === "Escape") {
e.preventDefault();
closeSearch();
}
});
pageInputEl.addEventListener("change", () => {
const page = parseInt(pageInputEl.value, 10);
if (!isNaN(page)) {
@ -568,6 +892,25 @@ pageInputEl.addEventListener("keydown", (e) => {
// Keyboard navigation
document.addEventListener("keydown", (e) => {
// Ctrl/Cmd+F: open our search if closed; if already focused, pass through to browser find
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
if (!searchOpen) {
e.preventDefault();
openSearch();
} else if (document.activeElement === searchInputEl) {
// Already focused — close ours and let browser find open
closeSearch();
} else {
// Open but not focused — re-focus our search
e.preventDefault();
searchInputEl.focus();
searchInputEl.select();
}
return;
}
// Don't handle nav shortcuts when search input is focused
if (document.activeElement === searchInputEl) return;
if (document.activeElement === pageInputEl) return;
// Ctrl/Cmd+0 to reset zoom
@ -579,7 +922,10 @@ document.addEventListener("keydown", (e) => {
switch (e.key) {
case "Escape":
if (currentDisplayMode === "fullscreen") {
if (searchOpen) {
closeSearch();
e.preventDefault();
} else if (currentDisplayMode === "fullscreen") {
toggleFullscreen();
e.preventDefault();
}