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.
|
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 |
|
| 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` | 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/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/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) |
|
| `@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).
|
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 id="loading" class="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p id="loading-text">Loading PDF...</p>
|
<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 id="progress-bar" class="progress-bar"></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="progress-text" class="progress-text"></p>
|
<p id="progress-text" class="progress-text"></p>
|
||||||
|
|
@ -57,16 +61,58 @@
|
||||||
<button id="zoom-in-btn" class="zoom-btn" title="Zoom in (+)">
|
<button id="zoom-in-btn" class="zoom-btn" title="Zoom in (+)">
|
||||||
+
|
+
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Single Page Canvas Container -->
|
||||||
<div class="canvas-container">
|
<div class="canvas-container">
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<canvas id="pdf-canvas"></canvas>
|
<canvas id="pdf-canvas"></canvas>
|
||||||
|
<div id="highlight-layer" class="highlight-layer"></div>
|
||||||
<div id="text-layer" class="text-layer"></div>
|
<div id="text-layer" class="text-layer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@
|
||||||
--text200: light-dark(#999999, #888888);
|
--text200: light-dark(#999999, #888888);
|
||||||
|
|
||||||
/* Shadows */
|
/* 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));
|
--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-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toolbar */
|
/* Toolbar */
|
||||||
|
|
@ -256,7 +260,10 @@ body {
|
||||||
display: block;
|
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 {
|
.text-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -270,10 +277,11 @@ body {
|
||||||
forced-color-adjust: none;
|
forced-color-adjust: none;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
z-index: 2;
|
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 :is(span, br) {
|
||||||
.text-layer br {
|
|
||||||
color: transparent;
|
color: transparent;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
@ -281,6 +289,23 @@ body {
|
||||||
transform-origin: 0% 0%;
|
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 {
|
.text-layer ::selection {
|
||||||
background: var(--selection-bg);
|
background: var(--selection-bg);
|
||||||
}
|
}
|
||||||
|
|
@ -310,3 +335,127 @@ body {
|
||||||
min-height: 0; /* Allow flex item to shrink below content size */
|
min-height: 0; /* Allow flex item to shrink below content size */
|
||||||
overflow: auto; /* Scroll within the document area only */
|
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 progressContainerEl = document.getElementById("progress-container")!;
|
||||||
const progressBarEl = document.getElementById("progress-bar")!;
|
const progressBarEl = document.getElementById("progress-bar")!;
|
||||||
const progressTextEl = document.getElementById("progress-text")!;
|
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
|
// Track current display mode
|
||||||
let currentDisplayMode: "inline" | "fullscreen" = "inline";
|
let currentDisplayMode: "inline" | "fullscreen" = "inline";
|
||||||
|
|
@ -106,16 +138,262 @@ function requestFitToContent() {
|
||||||
const paddingBottom = parseFloat(containerStyle.paddingBottom);
|
const paddingBottom = parseFloat(containerStyle.paddingBottom);
|
||||||
|
|
||||||
// Calculate required height:
|
// 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 toolbarHeight = toolbarEl.offsetHeight;
|
||||||
|
const searchBarHeight = searchOpen ? searchBarEl.offsetHeight : 0;
|
||||||
const pageWrapperHeight = pageWrapperEl.offsetHeight;
|
const pageWrapperHeight = pageWrapperEl.offsetHeight;
|
||||||
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
|
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
|
||||||
const totalHeight =
|
const totalHeight =
|
||||||
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
|
toolbarHeight +
|
||||||
|
searchBarHeight +
|
||||||
|
paddingTop +
|
||||||
|
pageWrapperHeight +
|
||||||
|
paddingBottom +
|
||||||
|
BUFFER;
|
||||||
|
|
||||||
app.sendSizeChanged({ height: totalHeight });
|
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
|
// Create app instance
|
||||||
// autoResize disabled - app fills its container, doesn't request size changes
|
// autoResize disabled - app fills its container, doesn't request size changes
|
||||||
const app = new App(
|
const app = new App(
|
||||||
|
|
@ -391,6 +669,8 @@ async function renderPage() {
|
||||||
textLayerEl.innerHTML = "";
|
textLayerEl.innerHTML = "";
|
||||||
textLayerEl.style.width = `${viewport.width}px`;
|
textLayerEl.style.width = `${viewport.width}px`;
|
||||||
textLayerEl.style.height = `${viewport.height}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
|
// Render canvas - track the task so we can cancel it
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|
@ -430,6 +710,24 @@ async function renderPage() {
|
||||||
});
|
});
|
||||||
await textLayer.render();
|
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();
|
updateControls();
|
||||||
updatePageContext();
|
updatePageContext();
|
||||||
|
|
||||||
|
|
@ -549,8 +847,34 @@ prevBtn.addEventListener("click", prevPage);
|
||||||
nextBtn.addEventListener("click", nextPage);
|
nextBtn.addEventListener("click", nextPage);
|
||||||
zoomOutBtn.addEventListener("click", zoomOut);
|
zoomOutBtn.addEventListener("click", zoomOut);
|
||||||
zoomInBtn.addEventListener("click", zoomIn);
|
zoomInBtn.addEventListener("click", zoomIn);
|
||||||
|
searchBtn.addEventListener("click", toggleSearch);
|
||||||
|
searchCloseBtn.addEventListener("click", closeSearch);
|
||||||
|
searchPrevBtn.addEventListener("click", goToPrevMatch);
|
||||||
|
searchNextBtn.addEventListener("click", goToNextMatch);
|
||||||
fullscreenBtn.addEventListener("click", toggleFullscreen);
|
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", () => {
|
pageInputEl.addEventListener("change", () => {
|
||||||
const page = parseInt(pageInputEl.value, 10);
|
const page = parseInt(pageInputEl.value, 10);
|
||||||
if (!isNaN(page)) {
|
if (!isNaN(page)) {
|
||||||
|
|
@ -568,6 +892,25 @@ pageInputEl.addEventListener("keydown", (e) => {
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
document.addEventListener("keydown", (e) => {
|
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;
|
if (document.activeElement === pageInputEl) return;
|
||||||
|
|
||||||
// Ctrl/Cmd+0 to reset zoom
|
// Ctrl/Cmd+0 to reset zoom
|
||||||
|
|
@ -579,7 +922,10 @@ document.addEventListener("keydown", (e) => {
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "Escape":
|
case "Escape":
|
||||||
if (currentDisplayMode === "fullscreen") {
|
if (searchOpen) {
|
||||||
|
closeSearch();
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (currentDisplayMode === "fullscreen") {
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue