| |
| |
| |
| |
| |
| |
| |
| |
| import { App, type McpUiHostContext } from "@modelcontextprotocol/ext-apps"; |
| import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; |
| import * as pdfjsLib from "pdfjs-dist"; |
| import { TextLayer } from "pdfjs-dist"; |
| import "./global.css"; |
| import "./mcp-app.css"; |
|
|
| |
| const MAX_MODEL_CONTEXT_LENGTH = 1500; |
|
|
| |
| pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( |
| "pdfjs-dist/build/pdf.worker.mjs", |
| import.meta.url, |
| ).href; |
|
|
| const log = { |
| info: console.log.bind(console, "[PDF-VIEWER]"), |
| error: console.error.bind(console, "[PDF-VIEWER]"), |
| }; |
|
|
| |
| let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null; |
| let pdfBytes: Uint8Array | null = null; |
| let currentPage = 1; |
| let totalPages = 0; |
| let scale = 1.0; |
| let pdfUrl = ""; |
| let pdfTitle: string | undefined; |
| let currentRenderTask: { cancel: () => void } | null = null; |
|
|
| |
| const mainEl = document.querySelector(".main") as HTMLElement; |
| const loadingEl = document.getElementById("loading")!; |
| const loadingTextEl = document.getElementById("loading-text")!; |
| const errorEl = document.getElementById("error")!; |
| const errorMessageEl = document.getElementById("error-message")!; |
| const viewerEl = document.getElementById("viewer")!; |
| const canvasContainerEl = document.querySelector(".canvas-container")!; |
| const canvasEl = document.getElementById("pdf-canvas") as HTMLCanvasElement; |
| const textLayerEl = document.getElementById("text-layer")!; |
| const titleEl = document.getElementById("pdf-title")!; |
| const pageInputEl = document.getElementById("page-input") as HTMLInputElement; |
| const totalPagesEl = document.getElementById("total-pages")!; |
| const prevBtn = document.getElementById("prev-btn") as HTMLButtonElement; |
| const nextBtn = document.getElementById("next-btn") as HTMLButtonElement; |
| const zoomOutBtn = document.getElementById("zoom-out-btn") as HTMLButtonElement; |
| const zoomInBtn = document.getElementById("zoom-in-btn") as HTMLButtonElement; |
| const zoomLevelEl = document.getElementById("zoom-level")!; |
| const fullscreenBtn = document.getElementById( |
| "fullscreen-btn", |
| ) as HTMLButtonElement; |
| const progressContainerEl = document.getElementById("progress-container")!; |
| const progressBarEl = document.getElementById("progress-bar")!; |
| const progressTextEl = document.getElementById("progress-text")!; |
|
|
| |
| let currentDisplayMode: "inline" | "fullscreen" = "inline"; |
|
|
| |
|
|
| |
| |
| |
| |
| function requestFitToContent() { |
| if (currentDisplayMode === "fullscreen") { |
| return; |
| } |
|
|
| const canvasHeight = canvasEl.height; |
| if (canvasHeight <= 0) { |
| return; |
| } |
|
|
| |
| const canvasContainerEl = document.querySelector( |
| ".canvas-container", |
| ) as HTMLElement; |
| const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement; |
| const toolbarEl = document.querySelector(".toolbar") as HTMLElement; |
|
|
| if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) { |
| return; |
| } |
|
|
| |
| const containerStyle = getComputedStyle(canvasContainerEl); |
| const paddingTop = parseFloat(containerStyle.paddingTop); |
| const paddingBottom = parseFloat(containerStyle.paddingBottom); |
|
|
| |
| |
| const toolbarHeight = toolbarEl.offsetHeight; |
| const pageWrapperHeight = pageWrapperEl.offsetHeight; |
| const BUFFER = 10; |
| const totalHeight = |
| toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER; |
|
|
| app.sendSizeChanged({ height: totalHeight }); |
| } |
|
|
| |
| |
| const app = new App( |
| { name: "PDF Viewer", version: "1.0.0" }, |
| {}, |
| { autoResize: false }, |
| ); |
|
|
| |
| function showLoading(text: string) { |
| loadingTextEl.textContent = text; |
| loadingEl.style.display = "flex"; |
| errorEl.style.display = "none"; |
| viewerEl.style.display = "none"; |
| } |
|
|
| function showError(message: string) { |
| errorMessageEl.textContent = message; |
| loadingEl.style.display = "none"; |
| errorEl.style.display = "block"; |
| viewerEl.style.display = "none"; |
| } |
|
|
| function showViewer() { |
| loadingEl.style.display = "none"; |
| errorEl.style.display = "none"; |
| viewerEl.style.display = "flex"; |
| } |
|
|
| function updateControls() { |
| |
| titleEl.textContent = pdfUrl; |
| titleEl.title = pdfUrl; |
| titleEl.style.textDecoration = "underline"; |
| titleEl.style.cursor = "pointer"; |
| titleEl.onclick = () => app.openLink({ url: pdfUrl }); |
| pageInputEl.value = String(currentPage); |
| pageInputEl.max = String(totalPages); |
| totalPagesEl.textContent = `of ${totalPages}`; |
| prevBtn.disabled = currentPage <= 1; |
| nextBtn.disabled = currentPage >= totalPages; |
| zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function formatPageContent( |
| text: string, |
| maxLength: number, |
| selection?: { start: number; end: number }, |
| ): string { |
| const T = "<truncated-content/>"; |
|
|
| |
| if (text.length <= maxLength) { |
| if (!selection) return text; |
| return ( |
| text.slice(0, selection.start) + |
| `<pdf-selection>${text.slice(selection.start, selection.end)}</pdf-selection>` + |
| text.slice(selection.end) |
| ); |
| } |
|
|
| |
| if (!selection) { |
| return text.slice(0, maxLength) + "\n" + T; |
| } |
|
|
| |
| const selLen = selection.end - selection.start; |
| const overhead = "<pdf-selection></pdf-selection>".length + T.length * 2 + 4; |
| const contextBudget = maxLength - overhead; |
|
|
| |
| if (selLen > contextBudget) { |
| const keepLen = Math.max(100, contextBudget); |
| const halfKeep = Math.floor(keepLen / 2); |
| const selStart = text.slice(selection.start, selection.start + halfKeep); |
| const selEnd = text.slice(selection.end - halfKeep, selection.end); |
| return ( |
| T + `<pdf-selection>${T}${selStart}...${selEnd}${T}</pdf-selection>` + T |
| ); |
| } |
|
|
| |
| const remainingBudget = contextBudget - selLen; |
| const beforeBudget = Math.floor(remainingBudget / 2); |
| const afterBudget = remainingBudget - beforeBudget; |
|
|
| const windowStart = Math.max(0, selection.start - beforeBudget); |
| const windowEnd = Math.min(text.length, selection.end + afterBudget); |
|
|
| const adjStart = selection.start - windowStart; |
| const adjEnd = selection.end - windowStart; |
| const windowText = text.slice(windowStart, windowEnd); |
|
|
| return ( |
| (windowStart > 0 ? T + "\n" : "") + |
| windowText.slice(0, adjStart) + |
| `<pdf-selection>${windowText.slice(adjStart, adjEnd)}</pdf-selection>` + |
| windowText.slice(adjEnd) + |
| (windowEnd < text.length ? "\n" + T : "") |
| ); |
| } |
|
|
| |
| |
| |
| |
| function findSelectionInText( |
| pageText: string, |
| selectedText: string, |
| ): { start: number; end: number } | undefined { |
| if (!selectedText || selectedText.length <= 2) return undefined; |
|
|
| |
| let start = pageText.indexOf(selectedText); |
| if (start >= 0) { |
| return { start, end: start + selectedText.length }; |
| } |
|
|
| |
| const noSpaceSel = selectedText.replace(/\s+/g, ""); |
| const noSpaceText = pageText.replace(/\s+/g, ""); |
| const noSpaceStart = noSpaceText.indexOf(noSpaceSel); |
| if (noSpaceStart >= 0) { |
| |
| start = Math.floor((noSpaceStart / noSpaceText.length) * pageText.length); |
| return { start, end: start + selectedText.length }; |
| } |
|
|
| return undefined; |
| } |
|
|
| |
| async function updatePageContext() { |
| if (!pdfDocument) return; |
|
|
| try { |
| const page = await pdfDocument.getPage(currentPage); |
| const textContent = await page.getTextContent(); |
| const pageText = (textContent.items as Array<{ str?: string }>) |
| .map((item) => item.str || "") |
| .join(" ") |
| .replace(/\s+/g, " ") |
| .trim(); |
|
|
| |
| const sel = window.getSelection(); |
| const selectedText = sel?.toString().replace(/\s+/g, " ").trim(); |
| const selection = selectedText |
| ? findSelectionInText(pageText, selectedText) |
| : undefined; |
|
|
| if (selection) { |
| log.info( |
| "Selection found:", |
| selectedText?.slice(0, 30), |
| "at", |
| selection.start, |
| ); |
| } |
|
|
| |
| const content = formatPageContent( |
| pageText, |
| MAX_MODEL_CONTEXT_LENGTH, |
| selection, |
| ); |
|
|
| const markdown = `--- |
| title: ${pdfTitle || ""} |
| url: ${pdfUrl} |
| current-page: ${currentPage}/${totalPages} |
| --- |
| |
| ${content}`; |
|
|
| app.updateModelContext({ content: [{ type: "text", text: markdown }] }); |
| } catch (err) { |
| log.error("Error updating context:", err); |
| } |
| } |
|
|
| |
| let isRendering = false; |
| let pendingPage: number | null = null; |
|
|
| |
| async function renderPage() { |
| if (!pdfDocument) return; |
|
|
| |
| if (isRendering) { |
| pendingPage = currentPage; |
| |
| if (currentRenderTask) { |
| currentRenderTask.cancel(); |
| } |
| return; |
| } |
|
|
| isRendering = true; |
| pendingPage = null; |
|
|
| try { |
| const pageToRender = currentPage; |
| const page = await pdfDocument.getPage(pageToRender); |
| const viewport = page.getViewport({ scale }); |
|
|
| |
| const dpr = window.devicePixelRatio || 1; |
| const ctx = canvasEl.getContext("2d")!; |
|
|
| |
| canvasEl.width = viewport.width * dpr; |
| canvasEl.height = viewport.height * dpr; |
|
|
| |
| canvasEl.style.width = `${viewport.width}px`; |
| canvasEl.style.height = `${viewport.height}px`; |
|
|
| |
| ctx.scale(dpr, dpr); |
|
|
| |
| textLayerEl.innerHTML = ""; |
| textLayerEl.style.width = `${viewport.width}px`; |
| textLayerEl.style.height = `${viewport.height}px`; |
|
|
| |
| |
| const renderTask = (page.render as any)({ |
| canvasContext: ctx, |
| viewport, |
| }); |
| currentRenderTask = renderTask; |
|
|
| try { |
| await renderTask.promise; |
| } catch (renderErr) { |
| |
| if ( |
| renderErr instanceof Error && |
| renderErr.name === "RenderingCancelledException" |
| ) { |
| log.info("Render cancelled"); |
| return; |
| } |
| throw renderErr; |
| } finally { |
| currentRenderTask = null; |
| } |
|
|
| |
| if (pageToRender !== currentPage) { |
| return; |
| } |
|
|
| |
| const textContent = await page.getTextContent(); |
| const textLayer = new TextLayer({ |
| textContentSource: textContent, |
| container: textLayerEl, |
| viewport, |
| }); |
| await textLayer.render(); |
|
|
| updateControls(); |
| updatePageContext(); |
|
|
| |
| requestFitToContent(); |
| } catch (err) { |
| log.error("Error rendering page:", err); |
| showError(`Failed to render page ${currentPage}`); |
| } finally { |
| isRendering = false; |
|
|
| |
| if (pendingPage !== null && pendingPage !== currentPage) { |
| currentPage = pendingPage; |
| renderPage(); |
| } else if (pendingPage === currentPage) { |
| |
| renderPage(); |
| } |
| } |
| } |
|
|
| |
| function getStorageKey(): string | null { |
| if (!pdfUrl) return null; |
| const ctx = app.getHostContext(); |
| const toolId = ctx?.toolInfo?.id ?? pdfUrl; |
| return `pdf:${pdfUrl}:${toolId}`; |
| } |
|
|
| function saveCurrentPage() { |
| const key = getStorageKey(); |
| log.info("saveCurrentPage: key=", key, "page=", currentPage); |
| if (key) { |
| try { |
| localStorage.setItem(key, String(currentPage)); |
| log.info("saveCurrentPage: saved successfully"); |
| } catch (err) { |
| log.error("saveCurrentPage: error", err); |
| } |
| } |
| } |
|
|
| function loadSavedPage(): number | null { |
| const key = getStorageKey(); |
| log.info("loadSavedPage: key=", key); |
| if (!key) return null; |
| try { |
| const saved = localStorage.getItem(key); |
| log.info("loadSavedPage: saved value=", saved); |
| if (saved) { |
| const page = parseInt(saved, 10); |
| if (!isNaN(page) && page >= 1) { |
| log.info("loadSavedPage: returning page=", page); |
| return page; |
| } |
| } |
| } catch (err) { |
| log.error("loadSavedPage: error", err); |
| } |
| log.info("loadSavedPage: returning null"); |
| return null; |
| } |
|
|
| |
| function goToPage(page: number) { |
| const targetPage = Math.max(1, Math.min(page, totalPages)); |
| if (targetPage !== currentPage) { |
| currentPage = targetPage; |
| saveCurrentPage(); |
| renderPage(); |
| } |
| pageInputEl.value = String(currentPage); |
| } |
|
|
| function prevPage() { |
| goToPage(currentPage - 1); |
| } |
|
|
| function nextPage() { |
| goToPage(currentPage + 1); |
| } |
|
|
| function zoomIn() { |
| scale = Math.min(scale + 0.25, 3.0); |
| renderPage(); |
| } |
|
|
| function zoomOut() { |
| scale = Math.max(scale - 0.25, 0.5); |
| renderPage(); |
| } |
|
|
| function resetZoom() { |
| scale = 1.0; |
| renderPage(); |
| } |
|
|
| async function toggleFullscreen() { |
| const ctx = app.getHostContext(); |
| if (!ctx?.availableDisplayModes?.includes("fullscreen")) { |
| log.info("Fullscreen not available"); |
| return; |
| } |
|
|
| const newMode = currentDisplayMode === "fullscreen" ? "inline" : "fullscreen"; |
| log.info("Requesting display mode:", newMode); |
|
|
| try { |
| const result = await app.requestDisplayMode({ mode: newMode }); |
| log.info("Display mode result:", result); |
| currentDisplayMode = result.mode as "inline" | "fullscreen"; |
| updateFullscreenButton(); |
| } catch (err) { |
| log.error("Failed to change display mode:", err); |
| } |
| } |
|
|
| function updateFullscreenButton() { |
| fullscreenBtn.textContent = currentDisplayMode === "fullscreen" ? "⛶" : "⛶"; |
| fullscreenBtn.title = |
| currentDisplayMode === "fullscreen" ? "Exit fullscreen" : "Fullscreen"; |
| } |
|
|
| |
| prevBtn.addEventListener("click", prevPage); |
| nextBtn.addEventListener("click", nextPage); |
| zoomOutBtn.addEventListener("click", zoomOut); |
| zoomInBtn.addEventListener("click", zoomIn); |
| fullscreenBtn.addEventListener("click", toggleFullscreen); |
|
|
| pageInputEl.addEventListener("change", () => { |
| const page = parseInt(pageInputEl.value, 10); |
| if (!isNaN(page)) { |
| goToPage(page); |
| } else { |
| pageInputEl.value = String(currentPage); |
| } |
| }); |
|
|
| pageInputEl.addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { |
| pageInputEl.blur(); |
| } |
| }); |
|
|
| |
| document.addEventListener("keydown", (e) => { |
| if (document.activeElement === pageInputEl) return; |
|
|
| |
| if ((e.ctrlKey || e.metaKey) && e.key === "0") { |
| resetZoom(); |
| e.preventDefault(); |
| return; |
| } |
|
|
| switch (e.key) { |
| case "Escape": |
| if (currentDisplayMode === "fullscreen") { |
| toggleFullscreen(); |
| e.preventDefault(); |
| } |
| break; |
| case "ArrowLeft": |
| case "PageUp": |
| prevPage(); |
| e.preventDefault(); |
| break; |
| case "ArrowRight": |
| case "PageDown": |
| case " ": |
| nextPage(); |
| e.preventDefault(); |
| break; |
| case "+": |
| case "=": |
| zoomIn(); |
| e.preventDefault(); |
| break; |
| case "-": |
| zoomOut(); |
| e.preventDefault(); |
| break; |
| } |
| }); |
|
|
| |
| let selectionUpdateTimeout: ReturnType<typeof setTimeout> | null = null; |
| document.addEventListener("selectionchange", () => { |
| if (selectionUpdateTimeout) clearTimeout(selectionUpdateTimeout); |
| selectionUpdateTimeout = setTimeout(() => { |
| const sel = window.getSelection(); |
| const text = sel?.toString().trim(); |
| if (text && text.length > 2) { |
| log.info("Selection changed:", text.slice(0, 50)); |
| updatePageContext(); |
| } |
| }, 300); |
| }); |
|
|
| |
| let horizontalScrollAccumulator = 0; |
| const SCROLL_THRESHOLD = 50; |
|
|
| canvasContainerEl.addEventListener( |
| "wheel", |
| (event) => { |
| const e = event as WheelEvent; |
|
|
| |
| if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; |
|
|
| |
| if (scale > 1.0) return; |
|
|
| |
| e.preventDefault(); |
| horizontalScrollAccumulator += e.deltaX; |
| if (horizontalScrollAccumulator > SCROLL_THRESHOLD) { |
| nextPage(); |
| horizontalScrollAccumulator = 0; |
| } else if (horizontalScrollAccumulator < -SCROLL_THRESHOLD) { |
| prevPage(); |
| horizontalScrollAccumulator = 0; |
| } |
| }, |
| { passive: false }, |
| ); |
|
|
| |
| function parseToolResult(result: CallToolResult): { |
| url: string; |
| title?: string; |
| pageCount: number; |
| initialPage: number; |
| } | null { |
| return result.structuredContent as { |
| url: string; |
| title?: string; |
| pageCount: number; |
| initialPage: number; |
| } | null; |
| } |
|
|
| |
| interface PdfBytesChunk { |
| url: string; |
| bytes: string; |
| offset: number; |
| byteCount: number; |
| totalBytes: number; |
| hasMore: boolean; |
| } |
|
|
| |
| function updateProgress(loaded: number, total: number) { |
| const percent = Math.round((loaded / total) * 100); |
| progressBarEl.style.width = `${percent}%`; |
| progressTextEl.textContent = `${(loaded / 1024).toFixed(0)} KB / ${(total / 1024).toFixed(0)} KB (${percent}%)`; |
| } |
|
|
| |
| async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> { |
| const CHUNK_SIZE = 500 * 1024; |
| const chunks: Uint8Array[] = []; |
| let offset = 0; |
| let totalBytes = 0; |
| let hasMore = true; |
|
|
| |
| progressContainerEl.style.display = "block"; |
| updateProgress(0, 1); |
|
|
| while (hasMore) { |
| const result = await app.callServerTool({ |
| name: "read_pdf_bytes", |
| arguments: { url: urlToLoad, offset, byteCount: CHUNK_SIZE }, |
| }); |
|
|
| |
| if (result.isError) { |
| const errorText = result.content |
| ?.map((c) => ("text" in c ? c.text : "")) |
| .join(" "); |
| throw new Error(`Tool error: ${errorText}`); |
| } |
|
|
| if (!result.structuredContent) { |
| throw new Error("No structuredContent in tool response"); |
| } |
|
|
| const chunk = result.structuredContent as unknown as PdfBytesChunk; |
| totalBytes = chunk.totalBytes; |
| hasMore = chunk.hasMore; |
|
|
| |
| const binaryString = atob(chunk.bytes); |
| const bytes = new Uint8Array(binaryString.length); |
| for (let i = 0; i < binaryString.length; i++) { |
| bytes[i] = binaryString.charCodeAt(i); |
| } |
| chunks.push(bytes); |
|
|
| offset += chunk.byteCount; |
| updateProgress(offset, totalBytes); |
| } |
|
|
| |
| const fullPdf = new Uint8Array(totalBytes); |
| let pos = 0; |
| for (const chunk of chunks) { |
| fullPdf.set(chunk, pos); |
| pos += chunk.length; |
| } |
|
|
| log.info( |
| `PDF loaded: ${(totalBytes / 1024).toFixed(0)} KB in ${chunks.length} chunks`, |
| ); |
| return fullPdf; |
| } |
|
|
| |
| app.ontoolresult = async (result) => { |
| log.info("Received tool result:", result); |
|
|
| const parsed = parseToolResult(result); |
| if (!parsed) { |
| showError("Invalid tool result"); |
| return; |
| } |
|
|
| pdfUrl = parsed.url; |
| pdfTitle = parsed.title; |
| totalPages = parsed.pageCount; |
|
|
| |
| const savedPage = loadSavedPage(); |
| currentPage = |
| savedPage && savedPage <= parsed.pageCount ? savedPage : parsed.initialPage; |
|
|
| log.info( |
| "URL:", |
| pdfUrl, |
| "Pages:", |
| parsed.pageCount, |
| "Starting:", |
| currentPage, |
| ); |
|
|
| showLoading("Loading PDF..."); |
|
|
| try { |
| pdfBytes = await loadPdfInChunks(pdfUrl); |
|
|
| showLoading("Rendering PDF..."); |
|
|
| pdfDocument = await pdfjsLib.getDocument({ data: pdfBytes }).promise; |
| totalPages = pdfDocument.numPages; |
|
|
| log.info("PDF loaded, pages:", totalPages); |
|
|
| showViewer(); |
| renderPage(); |
| } catch (err) { |
| log.error("Error loading PDF:", err); |
| showError(err instanceof Error ? err.message : String(err)); |
| } |
| }; |
|
|
| app.onerror = (err) => { |
| log.error("App error:", err); |
| showError(err instanceof Error ? err.message : String(err)); |
| }; |
|
|
| function handleHostContextChanged(ctx: McpUiHostContext) { |
| log.info("Host context changed:", ctx); |
|
|
| |
| if (ctx.safeAreaInsets) { |
| mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; |
| mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; |
| mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; |
| mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; |
| } |
|
|
| |
| if (ctx.containerDimensions) { |
| log.info("Container dimensions:", ctx.containerDimensions); |
| } |
|
|
| |
| if (ctx.displayMode) { |
| const wasFullscreen = currentDisplayMode === "fullscreen"; |
| currentDisplayMode = ctx.displayMode as "inline" | "fullscreen"; |
| if (ctx.displayMode === "fullscreen") { |
| mainEl.classList.add("fullscreen"); |
| log.info("Fullscreen mode enabled"); |
| } else { |
| mainEl.classList.remove("fullscreen"); |
| log.info("Inline mode"); |
| |
| if (wasFullscreen && pdfDocument) { |
| requestFitToContent(); |
| } |
| } |
| updateFullscreenButton(); |
| } |
| } |
|
|
| app.onhostcontextchanged = handleHostContextChanged; |
|
|
| |
| app.connect().then(() => { |
| log.info("Connected to host"); |
| const ctx = app.getHostContext(); |
| if (ctx) { |
| handleHostContextChanged(ctx); |
| } |
| }); |
|
|