* fix(map-server): simplify location update message format * fix(say-server): pass host to streamable_http_app for non-localhost deployments * refactor(pdf-server): simplify to stateless range-query architecture - Remove caching/indexing in favor of direct HTTP Range requests - Use HEAD requests to get file size (no full download) - Use Range requests to proxy byte chunks (no caching) - Local files: fs.read with offset/length - Remote files: HTTP Range header - Zero memory footprint (no PDF caching) - Truly stateless (no session management needed) - Single server.ts (~350 lines) + main.ts CLI entry point - Instant startup (no preloading) - Security: whitelist local files, allowed origins Removed files: - src/pdf-indexer.ts - src/pdf-loader.ts - src/types.ts * feat(pdf-server): add dark mode support - Add CSS variables with light-dark() function for automatic theme switching - Sync theme with host via applyDocumentTheme() - Apply host CSS variables via applyHostStyleVariables() * feat(pdf-server): add list_pdfs tool back Lists local files and allowed remote origins. Also moves CHUNK_SIZE to module level constant. * feat(pdf-server): improve tool descriptions and increase context limit - display_pdf: list all allowed domains in description - Increase MAX_MODEL_CONTEXT_LENGTH to 15000 - Move CHUNK_SIZE to module level constant * chore(say-server): remove deployment docs from repo Keep server.py fix for non-localhost deployments. Deployment docs kept locally but not in repo. * refactor(pdf-server): simplify updateModelContext format - Add widget ID for multi-widget disambiguation - Use concise header: 'PDF viewer (id) | title | Current Page: X/Y' - Add 'Page content:' prefix before text - Keep selection markers (<pdf-selection>...</pdf-selection>) * refactor: rename widgetUUID to viewUUID for consistency Aligns with main's standardization on 'view' terminology (PR #325). Also dynamically generate allowed domains in display_pdf description. * viewUUID * fix(map-server): fix syntax error in location update message * fix(pdf-server): restore package.json, improve server docs - Restore --external pdfjs-dist in build - Rewrite server.ts header for newcomers (list tools, not implementation details) - Minimize main.ts diff * chore(pdf-server): remove unused addAllowedOrigin/addAllowedLocalFile * docs(pdf-server): update README for new architecture * docs(pdf-server): restore full README, add theming section, remove get_pdf_info * rm * fix(pdf-server): restore createMcpExpressApp and error handling - Use createMcpExpressApp for DNS rebinding protection - Restore error callback in app.listen() * fix(say-server): pass host to streamable_http_app for non-localhost deployments * chore(say-server): remove deployment docs from repo * widgetId->tool id * fix(map-server): add null check for center in location update chore(pdf-server): dedupe and sort allowed origins * fix(map-server): use transparent background for rounded corners * prettier
806 lines
23 KiB
TypeScript
806 lines
23 KiB
TypeScript
/**
|
|
* PDF Viewer MCP App
|
|
*
|
|
* Interactive PDF viewer with single-page display.
|
|
* - Fixed height (no auto-resize)
|
|
* - Text selection via PDF.js TextLayer
|
|
* - Page navigation, zoom
|
|
*/
|
|
import {
|
|
App,
|
|
type McpUiHostContext,
|
|
applyDocumentTheme,
|
|
applyHostStyleVariables,
|
|
} 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 = 15000;
|
|
const CHUNK_SIZE = 500 * 1024; // 500KB chunks
|
|
|
|
// Configure PDF.js worker
|
|
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]"),
|
|
};
|
|
|
|
// State
|
|
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 viewUUID: string | undefined;
|
|
let currentRenderTask: { cancel: () => void } | null = null;
|
|
|
|
// DOM Elements
|
|
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")!;
|
|
|
|
// Track current display mode
|
|
let currentDisplayMode: "inline" | "fullscreen" = "inline";
|
|
|
|
// Layout constants are no longer used - we calculate dynamically from actual element dimensions
|
|
|
|
/**
|
|
* Request the host to resize the app to fit the current PDF page.
|
|
* Only applies in inline mode - fullscreen mode uses scrolling.
|
|
*/
|
|
function requestFitToContent() {
|
|
if (currentDisplayMode === "fullscreen") {
|
|
return; // Fullscreen uses scrolling
|
|
}
|
|
|
|
const canvasHeight = canvasEl.height;
|
|
if (canvasHeight <= 0) {
|
|
return; // No content yet
|
|
}
|
|
|
|
// Get actual element dimensions
|
|
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;
|
|
}
|
|
|
|
// Get computed styles
|
|
const containerStyle = getComputedStyle(canvasContainerEl);
|
|
const paddingTop = parseFloat(containerStyle.paddingTop);
|
|
const paddingBottom = parseFloat(containerStyle.paddingBottom);
|
|
|
|
// Calculate required height:
|
|
// toolbar + padding-top + page-wrapper height + padding-bottom + buffer
|
|
const toolbarHeight = toolbarEl.offsetHeight;
|
|
const pageWrapperHeight = pageWrapperEl.offsetHeight;
|
|
const BUFFER = 10; // Buffer for sub-pixel rounding and browser quirks
|
|
const totalHeight =
|
|
toolbarHeight + paddingTop + pageWrapperHeight + paddingBottom + BUFFER;
|
|
|
|
app.sendSizeChanged({ height: totalHeight });
|
|
}
|
|
|
|
// Create app instance
|
|
// autoResize disabled - app fills its container, doesn't request size changes
|
|
const app = new App(
|
|
{ name: "PDF Viewer", version: "1.0.0" },
|
|
{},
|
|
{ autoResize: false },
|
|
);
|
|
|
|
// UI State functions
|
|
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() {
|
|
// Show URL with CSS ellipsis, full URL as tooltip, clickable to open
|
|
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)}%`;
|
|
}
|
|
|
|
/**
|
|
* Format page text with optional selection, truncating intelligently.
|
|
* - Centers window around selection when truncating
|
|
* - Adds <truncated-content/> markers where text is elided
|
|
* - If selection itself is too long, truncates inside: <pdf-selection><truncated-content/>...<truncated-content/></pdf-selection>
|
|
*/
|
|
function formatPageContent(
|
|
text: string,
|
|
maxLength: number,
|
|
selection?: { start: number; end: number },
|
|
): string {
|
|
const T = "<truncated-content/>";
|
|
|
|
// No truncation needed
|
|
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)
|
|
);
|
|
}
|
|
|
|
// Truncation needed, no selection - just truncate end
|
|
if (!selection) {
|
|
return text.slice(0, maxLength) + "\n" + T;
|
|
}
|
|
|
|
// Calculate budgets
|
|
const selLen = selection.end - selection.start;
|
|
const overhead = "<pdf-selection></pdf-selection>".length + T.length * 2 + 4;
|
|
const contextBudget = maxLength - overhead;
|
|
|
|
// Selection too long - truncate inside the selection tags
|
|
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
|
|
);
|
|
}
|
|
|
|
// Selection fits - center it with context
|
|
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 : "")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find selection position in page text using fuzzy matching.
|
|
* TextLayer spans may lack spaces between them, so we try both exact and spaceless match.
|
|
*/
|
|
function findSelectionInText(
|
|
pageText: string,
|
|
selectedText: string,
|
|
): { start: number; end: number } | undefined {
|
|
if (!selectedText || selectedText.length <= 2) return undefined;
|
|
|
|
// Try exact match
|
|
let start = pageText.indexOf(selectedText);
|
|
if (start >= 0) {
|
|
return { start, end: start + selectedText.length };
|
|
}
|
|
|
|
// Try spaceless match (TextLayer spans may not have spaces)
|
|
const noSpaceSel = selectedText.replace(/\s+/g, "");
|
|
const noSpaceText = pageText.replace(/\s+/g, "");
|
|
const noSpaceStart = noSpaceText.indexOf(noSpaceSel);
|
|
if (noSpaceStart >= 0) {
|
|
// Map back to approximate position in original
|
|
start = Math.floor((noSpaceStart / noSpaceText.length) * pageText.length);
|
|
return { start, end: start + selectedText.length };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
// Extract text from current page and update model context
|
|
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();
|
|
|
|
// Find selection position
|
|
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,
|
|
);
|
|
}
|
|
|
|
// Format content with selection markers and truncation
|
|
const content = formatPageContent(
|
|
pageText,
|
|
MAX_MODEL_CONTEXT_LENGTH,
|
|
selection,
|
|
);
|
|
|
|
// Build context with tool ID for multi-tool disambiguation
|
|
const toolId = app.getHostContext()?.toolInfo?.id;
|
|
const header = [
|
|
`PDF viewer${toolId ? ` (${toolId})` : ""}`,
|
|
pdfTitle ? `"${pdfTitle}"` : pdfUrl,
|
|
`Current Page: ${currentPage}/${totalPages}`,
|
|
].join(" | ");
|
|
|
|
const contextText = `${header}\n\nPage content:\n${content}`;
|
|
|
|
app.updateModelContext({ content: [{ type: "text", text: contextText }] });
|
|
} catch (err) {
|
|
log.error("Error updating context:", err);
|
|
}
|
|
}
|
|
|
|
// Render state - prevents concurrent renders
|
|
let isRendering = false;
|
|
let pendingPage: number | null = null;
|
|
|
|
// Render current page with text layer for selection
|
|
async function renderPage() {
|
|
if (!pdfDocument) return;
|
|
|
|
// If already rendering, queue this page for later
|
|
if (isRendering) {
|
|
pendingPage = currentPage;
|
|
// Cancel current render to speed up
|
|
if (currentRenderTask) {
|
|
currentRenderTask.cancel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
isRendering = true;
|
|
pendingPage = null;
|
|
|
|
try {
|
|
const pageToRender = currentPage;
|
|
const page = await pdfDocument.getPage(pageToRender);
|
|
const viewport = page.getViewport({ scale });
|
|
|
|
// Account for retina displays
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const ctx = canvasEl.getContext("2d")!;
|
|
|
|
// Set canvas size in pixels (scaled for retina)
|
|
canvasEl.width = viewport.width * dpr;
|
|
canvasEl.height = viewport.height * dpr;
|
|
|
|
// Set display size in CSS pixels
|
|
canvasEl.style.width = `${viewport.width}px`;
|
|
canvasEl.style.height = `${viewport.height}px`;
|
|
|
|
// Scale context for retina
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// Clear and setup text layer
|
|
textLayerEl.innerHTML = "";
|
|
textLayerEl.style.width = `${viewport.width}px`;
|
|
textLayerEl.style.height = `${viewport.height}px`;
|
|
|
|
// Render canvas - track the task so we can cancel it
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const renderTask = (page.render as any)({
|
|
canvasContext: ctx,
|
|
viewport,
|
|
});
|
|
currentRenderTask = renderTask;
|
|
|
|
try {
|
|
await renderTask.promise;
|
|
} catch (renderErr) {
|
|
// Ignore RenderingCancelledException - it's expected when we cancel
|
|
if (
|
|
renderErr instanceof Error &&
|
|
renderErr.name === "RenderingCancelledException"
|
|
) {
|
|
log.info("Render cancelled");
|
|
return;
|
|
}
|
|
throw renderErr;
|
|
} finally {
|
|
currentRenderTask = null;
|
|
}
|
|
|
|
// Only continue if this is still the page we want
|
|
if (pageToRender !== currentPage) {
|
|
return;
|
|
}
|
|
|
|
// Render text layer for selection
|
|
const textContent = await page.getTextContent();
|
|
const textLayer = new TextLayer({
|
|
textContentSource: textContent,
|
|
container: textLayerEl,
|
|
viewport,
|
|
});
|
|
await textLayer.render();
|
|
|
|
updateControls();
|
|
updatePageContext();
|
|
|
|
// Request host to resize app to fit content (inline mode only)
|
|
requestFitToContent();
|
|
} catch (err) {
|
|
log.error("Error rendering page:", err);
|
|
showError(`Failed to render page ${currentPage}`);
|
|
} finally {
|
|
isRendering = false;
|
|
|
|
// If there's a pending page, render it now
|
|
if (pendingPage !== null && pendingPage !== currentPage) {
|
|
currentPage = pendingPage;
|
|
renderPage();
|
|
} else if (pendingPage === currentPage) {
|
|
// Re-render the same page (e.g., after zoom change during render)
|
|
renderPage();
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveCurrentPage() {
|
|
log.info("saveCurrentPage: key=", viewUUID, "page=", currentPage);
|
|
if (viewUUID) {
|
|
try {
|
|
localStorage.setItem(viewUUID, String(currentPage));
|
|
log.info("saveCurrentPage: saved successfully");
|
|
} catch (err) {
|
|
log.error("saveCurrentPage: error", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadSavedPage(): number | null {
|
|
log.info("loadSavedPage: key=", viewUUID);
|
|
if (!viewUUID) return null;
|
|
try {
|
|
const saved = localStorage.getItem(viewUUID);
|
|
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;
|
|
}
|
|
|
|
// Navigation
|
|
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";
|
|
}
|
|
|
|
// Event listeners
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Keyboard navigation
|
|
document.addEventListener("keydown", (e) => {
|
|
if (document.activeElement === pageInputEl) return;
|
|
|
|
// Ctrl/Cmd+0 to reset zoom
|
|
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;
|
|
}
|
|
});
|
|
|
|
// Update context when text selection changes (debounced)
|
|
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);
|
|
});
|
|
|
|
// Horizontal scroll/swipe to change pages (disabled when zoomed)
|
|
let horizontalScrollAccumulator = 0;
|
|
const SCROLL_THRESHOLD = 50;
|
|
|
|
canvasContainerEl.addEventListener(
|
|
"wheel",
|
|
(event) => {
|
|
const e = event as WheelEvent;
|
|
|
|
// Only intercept horizontal scroll, let vertical scroll through
|
|
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
|
|
|
|
// When zoomed, let natural panning happen (no page changes)
|
|
if (scale > 1.0) return;
|
|
|
|
// At 100% zoom, handle page navigation
|
|
e.preventDefault();
|
|
horizontalScrollAccumulator += e.deltaX;
|
|
if (horizontalScrollAccumulator > SCROLL_THRESHOLD) {
|
|
nextPage();
|
|
horizontalScrollAccumulator = 0;
|
|
} else if (horizontalScrollAccumulator < -SCROLL_THRESHOLD) {
|
|
prevPage();
|
|
horizontalScrollAccumulator = 0;
|
|
}
|
|
},
|
|
{ passive: false },
|
|
);
|
|
|
|
// Parse tool result
|
|
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;
|
|
}
|
|
|
|
// Chunked binary loading types
|
|
interface PdfBytesChunk {
|
|
url: string;
|
|
bytes: string;
|
|
offset: number;
|
|
byteCount: number;
|
|
totalBytes: number;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
// Update progress bar
|
|
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}%)`;
|
|
}
|
|
|
|
// Load PDF in chunks with progress
|
|
async function loadPdfInChunks(urlToLoad: string): Promise<Uint8Array> {
|
|
const chunks: Uint8Array[] = [];
|
|
let offset = 0;
|
|
let totalBytes = 0;
|
|
let hasMore = true;
|
|
|
|
// Show progress UI
|
|
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 },
|
|
});
|
|
|
|
// Check for errors
|
|
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;
|
|
|
|
// Decode base64 chunk
|
|
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);
|
|
}
|
|
|
|
// Combine all chunks
|
|
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;
|
|
}
|
|
|
|
// Handle tool result
|
|
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;
|
|
viewUUID = result._meta?.viewUUID ? String(result._meta.viewUUID) : undefined;
|
|
|
|
// Restore saved page or use initial page
|
|
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);
|
|
|
|
// Apply theme from host
|
|
if (ctx.theme) {
|
|
applyDocumentTheme(ctx.theme);
|
|
}
|
|
|
|
// Apply host CSS variables
|
|
if (ctx.styles?.variables) {
|
|
applyHostStyleVariables(ctx.styles.variables);
|
|
}
|
|
|
|
// Apply safe area insets
|
|
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`;
|
|
}
|
|
|
|
// Log containerDimensions for debugging
|
|
if (ctx.containerDimensions) {
|
|
log.info("Container dimensions:", ctx.containerDimensions);
|
|
}
|
|
|
|
// Handle display mode changes
|
|
if (ctx.displayMode) {
|
|
const wasFullscreen = currentDisplayMode === "fullscreen";
|
|
currentDisplayMode = ctx.displayMode as "inline" | "fullscreen";
|
|
const isFullscreen = currentDisplayMode === "fullscreen";
|
|
mainEl.classList.toggle("fullscreen", isFullscreen);
|
|
log.info(isFullscreen ? "Fullscreen mode enabled" : "Inline mode");
|
|
// When exiting fullscreen, request resize to fit content
|
|
if (wasFullscreen && !isFullscreen && pdfDocument) {
|
|
requestFitToContent();
|
|
}
|
|
updateFullscreenButton();
|
|
}
|
|
}
|
|
|
|
app.onhostcontextchanged = handleHostContextChanged;
|
|
|
|
// Connect to host
|
|
app.connect().then(() => {
|
|
log.info("Connected to host");
|
|
const ctx = app.getHostContext();
|
|
if (ctx) {
|
|
handleHostContextChanged(ctx);
|
|
}
|
|
});
|