diff --git a/README.md b/README.md index 3b4370876..de0c4ade7 100644 --- a/README.md +++ b/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). diff --git a/examples/pdf-server/mcp-app.html b/examples/pdf-server/mcp-app.html index cf217235d..8ee8b067e 100644 --- a/examples/pdf-server/mcp-app.html +++ b/examples/pdf-server/mcp-app.html @@ -11,7 +11,11 @@

Loading PDF...

-
+ +
+
diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 7600be515..286dc6141 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -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; + } +} diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index ad2b8bf0f..e5299a55a 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -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 = ``; +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 | null = null; +const pageTextCache = new Map(); +const pageTextItemsCache = new Map(); +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(); }