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:
parent
f615123711
commit
8cad9f04c0
4 changed files with 556 additions and 15 deletions
12
README.md
12
README.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
id="search-next-btn"
|
||||
class="search-nav-btn"
|
||||
title="Next (Enter)"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
id="search-close-btn"
|
||||
class="search-nav-btn"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
✕
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue