/**
* 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,
ContentBlock,
} from "@modelcontextprotocol/client";
import { z } from "zod";
import * as pdfjsLib from "pdfjs-dist";
import { AnnotationLayer, AnnotationMode, TextLayer } from "pdfjs-dist";
import "pdfjs-dist/web/pdf_viewer.css";
import {
type PdfAnnotationDef,
type Rect,
type RectangleAnnotation,
type CircleAnnotation,
type LineAnnotation,
type StampAnnotation,
type ImageAnnotation,
type ImportedAnnotation,
type NoteAnnotation,
type FreetextAnnotation,
cssColorToRgb,
serializeDiff,
deserializeDiff,
mergeAnnotations,
computeDiff,
isDiffEmpty,
buildAnnotatedPdfBytes,
parseAnnotationRef,
importPdfjsAnnotation,
uint8ArrayToBase64,
convertFromModelCoords,
convertToModelCoords,
} from "./pdf-annotations.js";
import {
type TrackedAnnotation,
type EditEntry,
annotationMap,
formFieldValues,
pdfBaselineFormValues,
selectedAnnotationIds,
fieldNameToIds,
fieldNameToPage,
fieldNameToLabel,
fieldNameToOrder,
undoStack,
redoStack,
searchBarEl,
formLayerEl,
} from "./viewer-state.js";
import {
panelState,
annotationsPanelEl,
annotationsPanelListEl,
renderAnnotationPanel,
updateAnnotationsBadge,
setAnnotationPanelOpen,
applyFloatingPanelPosition,
autoDockPanel,
initAnnotationPanel,
syncSidebarSelection,
getFormFieldLabel,
getAnnotationLabel,
getAnnotationPreview,
getAnnotationColor,
} from "./annotation-panel.js";
import "./global.css";
import "./mcp-app.css";
const MAX_MODEL_CONTEXT_LENGTH = 15000;
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.mjs",
import.meta.url,
).href;
// PDF Standard-14 fonts from CDN (requires unpkg.com in CSP connectDomains).
// Pinned to the bundled pdfjs-dist version so font glyph indices match.
const STANDARD_FONT_DATA_URL = `https://unpkg.com/pdfjs-dist@${pdfjsLib.version}/standard_fonts/`;
const log = {
info: console.log.bind(console, "[PDF-VIEWER]"),
error: console.error.bind(console, "[PDF-VIEWER]"),
};
/**
* Resolve an ImageAnnotation to a src string safe for `
`.
* Returns the parsed-and-reserialized URL (`URL.href`) rather than the
* raw input so CodeQL's taint tracker recognises the sanitisation barrier
* (js/xss, js/client-side-unvalidated-url-redirection). Blocks
* `javascript:` / `vbscript:` etc. The server normally resolves
* imageUrl → imageData before enqueueing; the imageUrl branch here is
* defense-in-depth for the server-side fetch-failure fallback.
*/
function safeImageSrc(def: {
imageData?: string;
mimeType?: string;
imageUrl?: string;
}): string | undefined {
if (def.imageData) {
return `data:${def.mimeType || "image/png"};base64,${def.imageData}`;
}
if (!def.imageUrl) return undefined;
try {
const parsed = new URL(def.imageUrl, document.baseURI);
if (
parsed.protocol === "https:" ||
parsed.protocol === "http:" ||
parsed.protocol === "data:" ||
parsed.protocol === "blob:"
) {
return parsed.href;
}
} catch {
// fall through
}
return undefined;
}
// State
let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.0;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let viewUUID: string | undefined;
let interactEnabled = false;
/** Server-reported writability of the underlying file (fs.access W_OK). */
let fileWritable = false;
let currentRenderTask: { cancel: () => void } | null = null;
// Shared annotation state (annotationMap, formFieldValues, selectedAnnotationIds,
// undoStack, redoStack, fieldNameTo*) lives in ./viewer-state.ts — imported above.
/** Cache loaded HTMLImageElement instances by annotation ID for canvas painting. */
const imageCache = new Map();
/** Annotations imported from the PDF file (baseline for diff computation). */
let pdfBaselineAnnotations: PdfAnnotationDef[] = [];
/** Pages whose native annotations have already been imported into the baseline. */
const baselineScannedPages = new Set();
/** Native-annotation ids the user deleted (from restored localStorage diff) —
* the lazy per-page scan must NOT re-add these to annotationMap. */
const restoredRemovedIds = new Set();
// Dirty flag — tracks unsaved local changes
let isDirty = false;
/** Whether we're currently restoring annotations (suppress dirty flag). */
let isRestoring = false;
/** Once the save button is shown, it stays visible (possibly disabled) until reload. */
let saveBtnEverShown = false;
/** True between save_pdf call and resolution; suppresses file_changed handling. */
let saveInProgress = false;
/** mtime returned by our most recent successful save_pdf. Compare against
* incoming file_changed.mtimeMs to suppress our own write's echo. */
let lastSavedMtime: number | null = null;
/** Incremented on every reload. Fetches/preloads from an older generation are
* discarded — prevents stale rangeCache entries and stale page renders. */
let loadGeneration = 0;
let focusedFieldName: string | null = null;
// Radio widget annotation ID → its export value (buttonValue). pdf.js
// creates without setting .value, so target.value
// defaults to "on"; this map lets the input listener report the real value.
const radioButtonValues = new Map();
// Cached result of doc.getFieldObjects() — needed for AnnotationLayer reset button support
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedFieldObjects: Record | 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 searchBtn = document.getElementById("search-btn") as HTMLButtonElement;
searchBtn.innerHTML = ``;
// searchBarEl → imported from ./viewer-state.js
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")!;
const annotationLayerEl = document.getElementById("annotation-layer")!;
const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
// formLayerEl → imported from ./viewer-state.js
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
const downloadBtn = document.getElementById(
"download-btn",
) as HTMLButtonElement;
const confirmDialogEl = document.getElementById(
"confirm-dialog",
) as HTMLDivElement;
const confirmTitleEl = document.getElementById("confirm-title")!;
const confirmBodyEl = document.getElementById("confirm-body")!;
const confirmDetailEl = document.getElementById("confirm-detail")!;
const confirmButtonsEl = document.getElementById("confirm-buttons")!;
// Annotation Panel DOM Elements & state → ./annotation-panel.ts (imported above)
// 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;
// Preload state — goToPage sets preloadPaused=true, renderPage's finally resets it.
// The preloader's while(preloadPaused) loop yields so interactive loads always win.
let preloadPaused = false;
let pagesLoaded = 0;
let preloadErrors: Array<{ page: number; err: unknown }> = [];
const loadingIndicatorEl = document.getElementById("loading-indicator")!;
const loadingIndicatorArc = loadingIndicatorEl.querySelector(
".loading-indicator-arc",
) as SVGCircleElement;
// Track current display mode
let currentDisplayMode: "inline" | "fullscreen" = "inline";
// Whether the user has manually zoomed (disables auto fit-to-width)
let userHasZoomed = false;
/**
* Compute the scale that best fits the PDF page to the container.
* Returns null only when the container hasn't laid out yet.
*
* Inline mode: fit-to-WIDTH capped at 1.0. We shrink to fit a narrow chat
* column but don't blow up past natural size — the iframe sizes itself to
* the page via sendSizeChanged, so growing past 1.0 would just make the
* iframe huge.
*
* Fullscreen mode: fit-to-PAGE capped at ZOOM_MAX. The whole page is visible
* without scrolling (min of width-fit and height-fit). On a wide screen this
* typically lands well above 1.0; on a phone in portrait, width is the
* tighter constraint and it degrades to fit-to-width.
*/
async function computeFitScale(): Promise {
if (!pdfDocument) return null;
try {
const page = await pdfDocument.getPage(currentPage);
const naturalViewport = page.getViewport({ scale: 1.0 });
const pageWidth = naturalViewport.width;
const pageHeight = naturalViewport.height;
const container = canvasContainerEl as HTMLElement;
const containerStyle = getComputedStyle(container);
const padX =
parseFloat(containerStyle.paddingLeft) +
parseFloat(containerStyle.paddingRight);
const padY =
parseFloat(containerStyle.paddingTop) +
parseFloat(containerStyle.paddingBottom);
const availableWidth = container.clientWidth - padX;
const availableHeight = container.clientHeight - padY;
if (availableWidth <= 0 || pageWidth <= 0) return null;
const widthFit = availableWidth / pageWidth;
if (currentDisplayMode !== "fullscreen") {
return Math.min(1.0, widthFit);
}
// Fullscreen: fit the WHOLE page. If height isn't measurable yet
// (early layout) fall back to width-fit.
const heightFit =
availableHeight > 0 && pageHeight > 0
? availableHeight / pageHeight
: widthFit;
return Math.min(ZOOM_MAX, widthFit, heightFit);
} catch {
return null;
}
}
/**
* Re-apply the auto-fit scale if the user hasn't taken over zoom. Runs on
* container resize (ResizeObserver) and display-mode transitions.
*
* The ResizeObserver path is the load-bearing one. Hosts disagree on
* what they send and when:
* - basic-host sends containerDimensions only at init, never on resize
* - Claude Desktop resizes the iframe and sends displayMode, but the
* iframe element may not have its new size yet when the message lands
* The observer fires after the iframe has actually laid out at the new
* size, so clientWidth is fresh. The hostContextChanged hooks are kept
* as a fast path / belt-and-suspenders.
*/
async function refitScale(): Promise {
if (!pdfDocument || userHasZoomed) return;
const fitScale = await computeFitScale();
if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) {
scale = fitScale;
log.info("Refit scale:", scale);
renderPage();
}
}
// Refit when the container actually changes size. In INLINE mode this is
// gated to width-GROWTH only — renderPage() → requestFitToContent() → host
// resizes iframe to the (smaller) page width would otherwise re-trigger the
// observer and walk the scale down to ZOOM_MIN. In FULLSCREEN
// requestFitToContent early-returns so there's no loop, and fit-to-page
// needs height changes too (rotation, browser chrome on mobile).
let lastContainerW = 0;
let lastContainerH = 0;
/** One-shot: refit on the next resize even if it's a shrink in inline mode.
* Set on fullscreen→inline so the page snaps to the new (smaller) width
* once the host has actually resized the iframe — the inline `grewW` gate
* would otherwise swallow that shrink. */
let forceNextResizeRefit = false;
const containerResizeObserver = new ResizeObserver(([entry]) => {
const { width: w, height: h } = entry.contentRect;
const grewW = w > lastContainerW + 1;
const changed =
Math.abs(w - lastContainerW) > 1 || Math.abs(h - lastContainerH) > 1;
lastContainerW = w;
lastContainerH = h;
if (forceNextResizeRefit && changed) {
forceNextResizeRefit = false;
refitScale();
} else if (currentDisplayMode === "fullscreen" ? changed : grewW) {
refitScale();
}
});
containerResizeObserver.observe(canvasContainerEl as HTMLElement);
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
*/
function requestFitToContent() {
// Read the host's current state, not our cached currentDisplayMode.
// currentDisplayMode defaults to "inline" and handleHostContextChanged
// only updates it `if (ctx.displayMode)` — if the host omits the field
// or the update lands one tick late, the cached value lies. We've seen
// this measure a near-empty pageWrapper (~85px = toolbar + padding) and
// shrink a fullscreen iframe to a sliver.
if (app.getHostContext()?.displayMode === "fullscreen") {
return;
}
const canvasHeight = canvasEl.height;
if (canvasHeight <= 0) {
return; // No content yet
}
// Get actual element dimensions
const toolbarEl = document.querySelector(".toolbar") as HTMLElement;
if (!toolbarEl) {
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
// Note: search bar is absolutely positioned over the document area, so excluded
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;
// In inline mode (this function early-returns for fullscreen) the side panel is hidden
const totalWidth = pageWrapperEl.offsetWidth + BUFFER;
// pageWrapper measuring ≈ 0 means the canvas hasn't laid out yet (early
// render, hidden ancestor, etc). Sending toolbar-height-only would shrink
// the iframe to a sliver. The next renderPage() will measure correctly.
if (pageWrapperHeight < toolbarHeight) {
log.info(
`requestFitToContent: pageWrapper ${pageWrapperHeight}px < toolbar ${toolbarHeight}px — skipping`,
);
return;
}
app.sendSizeChanged({ width: totalWidth, height: totalHeight });
}
// --- Search Functions ---
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);
}
}
// Update model context with search results
updatePageContext();
}
/**
* Silent search: populate matches and report via model context
* without opening the search bar or rendering highlights.
*/
function performSilentSearch(query: string) {
allMatches = [];
currentMatchIndex = -1;
searchQuery = query;
if (!query) {
updatePageContext();
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;
}
}
if (allMatches.length > 0) {
const idx = allMatches.findIndex((m) => m.pageNum >= currentPage);
currentMatchIndex = idx >= 0 ? idx : 0;
}
log.info(`Silent search "${query}": ${allMatches.length} matches`);
updatePageContext();
}
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;
const stillLoading = totalPages > 0 && pagesLoaded < totalPages;
const suffix = stillLoading ? " (loading\u2026)" : "";
if (allMatches.length === 0) {
searchMatchCountEl.textContent = hasQuery ? `No matches${suffix}` : "";
} else {
searchMatchCountEl.textContent = `${currentMatchIndex + 1} of ${allMatches.length}${suffix}`;
}
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();
if (panelState.open && annotationsPanelEl.classList.contains("floating")) {
applyFloatingPanelPosition();
}
// Text extraction is handled by the background preloader
}
function closeSearch() {
if (!searchOpen) return;
searchOpen = false;
searchBarEl.style.display = "none";
if (panelState.open && annotationsPanelEl.classList.contains("floating")) {
applyFloatingPanelPosition();
}
searchQuery = "";
searchInputEl.value = "";
allMatches = [];
currentMatchIndex = -1;
clearHighlights();
updateSearchUI();
}
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(
{ 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";
}
// ---------------------------------------------------------------------------
// Confirm dialog
// ---------------------------------------------------------------------------
interface ConfirmButton {
label: string;
primary?: boolean;
}
let activeConfirmResolve: ((i: number) => void) | null = null;
/**
* In-app confirmation overlay. Resolves to the clicked button index, the
* cancel index on Escape, or `-1` if pre-empted by another dialog. Callers
* should treat anything but the expected button index as "cancel".
*
* Button ordering follows the host's native convention: Cancel first,
* primary action last.
*
* @param detail Optional monospace string shown in a bordered box (e.g.
* a filename), matching the host's native dialog style.
*/
function showConfirmDialog(
title: string,
body: string,
buttons: ConfirmButton[],
detail?: string,
): Promise {
// Pre-empt any open dialog: resolve it as cancelled
if (activeConfirmResolve) {
activeConfirmResolve(-1);
activeConfirmResolve = null;
}
// Escape → first non-primary button (native Cancel-first ordering)
const nonPrimary = buttons.findIndex((b) => !b.primary);
const escIndex = nonPrimary >= 0 ? nonPrimary : buttons.length - 1;
confirmTitleEl.textContent = title;
confirmBodyEl.textContent = body;
confirmDetailEl.textContent = detail ?? "";
confirmButtonsEl.innerHTML = "";
confirmDialogEl.style.display = "flex";
return new Promise((resolve) => {
activeConfirmResolve = resolve;
const done = (i: number): void => {
if (activeConfirmResolve !== resolve) return; // already pre-empted
activeConfirmResolve = null;
confirmDialogEl.style.display = "none";
document.removeEventListener("keydown", onKey, true);
resolve(i);
};
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
done(escIndex);
}
};
document.addEventListener("keydown", onKey, true);
buttons.forEach((btn, i) => {
const el = document.createElement("button");
el.textContent = btn.label;
el.className = btn.primary
? "confirm-btn confirm-btn-primary"
: "confirm-btn";
el.addEventListener("click", () => done(i));
confirmButtonsEl.appendChild(el);
if (btn.primary) setTimeout(() => el.focus(), 0);
});
});
}
function setDirty(dirty: boolean): void {
if (isDirty === dirty) return;
isDirty = dirty;
updateTitleDisplay();
updateSaveBtn();
}
function updateSaveBtn(): void {
if (!fileWritable) {
saveBtn.style.display = "none";
return;
}
if (isDirty) {
saveBtn.style.display = "";
saveBtn.disabled = false;
saveBtnEverShown = true;
} else if (saveBtnEverShown) {
saveBtn.style.display = "";
saveBtn.disabled = true;
} else {
saveBtn.style.display = "none";
}
}
function updateTitleDisplay(): void {
const display = pdfTitle || pdfUrl;
titleEl.textContent = (isDirty ? "* " : "") + display;
titleEl.title = pdfUrl;
}
/**
* Debug overlay: fixed-position bubble, bottom-left. Pretty-printed JSON
* dump of whatever the server stuffed into `_meta._debug`. Tooltips inside
* sandboxed iframes are unreliable; this survives the cross-origin barrier
* and shows up in screenshots.
*/
function showDebugBubble(debug: unknown): void {
const bubble = document.createElement("div");
const base =
"position:fixed;bottom:8px;left:8px;z-index:99999;" +
"background:rgba(20,20,30,0.92);color:#cfe;padding:8px 12px;" +
"font:11px/1.4 monospace;border-radius:6px;" +
"box-shadow:0 2px 8px rgba(0,0,0,0.4);white-space:pre;cursor:pointer;" +
"transition:max-width 0.15s ease;";
// Collapsed: clip to 60vw. Hover: expand to fit full paths (up to ~96vw),
// scrollable both axes in case the JSON is tall.
const collapsed =
base +
"max-width:60vw;max-height:40vh;overflow:hidden;text-overflow:ellipsis;";
const expanded =
base + "max-width:calc(100vw - 32px);max-height:80vh;overflow:auto;";
bubble.style.cssText = collapsed;
// Latch expanded on click so hover-collapse doesn't fight text selection.
let pinned = false;
bubble.onmouseenter = () => {
bubble.style.cssText = expanded;
};
bubble.onmouseleave = () => {
if (!pinned) bubble.style.cssText = collapsed;
};
bubble.onclick = () => {
pinned = true;
bubble.style.cssText = expanded;
};
bubble.ondblclick = () => bubble.remove();
bubble.title = "Click: pin open • Double-click: dismiss";
bubble.textContent = "🐞 " + JSON.stringify(debug, null, 2);
document.body.appendChild(bubble);
}
function updateControls() {
// Show URL with CSS ellipsis, full URL as tooltip, clickable to open
updateTitleDisplay();
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 markers where text is elided
* - If selection itself is too long, truncates inside: ...
*/
function formatPageContent(
text: string,
maxLength: number,
selection?: { start: number; end: number },
): string {
const T = "";
// No truncation needed
if (text.length <= maxLength) {
if (!selection) return text;
return (
text.slice(0, selection.start) +
`${text.slice(selection.start, selection.end)}` +
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 = "".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 + `${T}${selStart}...${selEnd}${T}` + 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) +
`${windowText.slice(adjStart, adjEnd)}` +
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;
}
/**
* Format search results with excerpts for model context.
* Limits to first 20 matches to avoid overwhelming the context.
*/
function formatSearchResults(): string {
const MAX_RESULTS = 20;
const EXCERPT_RADIUS = 40; // characters around the match
const lines: string[] = [];
const totalMatchCount = allMatches.length;
const currentIdx = currentMatchIndex >= 0 ? currentMatchIndex : -1;
lines.push(
`\nSearch: "${searchQuery}" (${totalMatchCount} match${totalMatchCount !== 1 ? "es" : ""} across ${new Set(allMatches.map((m) => m.pageNum)).size} page${new Set(allMatches.map((m) => m.pageNum)).size !== 1 ? "s" : ""})`,
);
const displayed = allMatches.slice(0, MAX_RESULTS);
for (let i = 0; i < displayed.length; i++) {
const match = displayed[i];
const pageText = pageTextCache.get(match.pageNum) || "";
const start = Math.max(0, match.index - EXCERPT_RADIUS);
const end = Math.min(
pageText.length,
match.index + match.length + EXCERPT_RADIUS,
);
const before = pageText.slice(start, match.index).replace(/\n/g, " ");
const matched = pageText.slice(match.index, match.index + match.length);
const after = pageText
.slice(match.index + match.length, end)
.replace(/\n/g, " ");
const prefix = start > 0 ? "..." : "";
const suffix = end < pageText.length ? "..." : "";
const current = i === currentIdx ? " (current)" : "";
lines.push(
` [${i}] p.${match.pageNum}, offset ${match.index}${current}: ${prefix}${before}«${matched}»${after}${suffix}`,
);
}
if (totalMatchCount > MAX_RESULTS) {
lines.push(` ... and ${totalMatchCount - MAX_RESULTS} more matches`);
}
return lines.join("\n");
}
// 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,
);
// Get page dimensions in PDF points for model context
const viewport = page.getViewport({ scale: 1.0 });
const pageWidthPt = Math.round(viewport.width);
const pageHeightPt = Math.round(viewport.height);
// Build context with tool ID for multi-tool disambiguation
const toolId = app.getHostContext()?.toolInfo?.id;
const header = [
`PDF viewer${toolId ? ` (${toolId})` : ""}`,
viewUUID ? `viewUUID: ${viewUUID}` : null,
pdfTitle ? `"${pdfTitle}"` : pdfUrl,
`Current Page: ${currentPage}/${totalPages}`,
`Page size: ${pageWidthPt}×${pageHeightPt}pt (coordinates: origin at top-left, Y increases downward)`,
]
.filter(Boolean)
.join(" | ");
// Include search status if active
let searchSection = "";
if (searchOpen && searchQuery && allMatches.length > 0) {
searchSection = formatSearchResults();
} else if (searchOpen && searchQuery) {
searchSection = `\nSearch: "${searchQuery}" (no matches found)`;
}
// Include annotation details if any exist
let annotationSection = "";
if (annotationMap.size > 0) {
const onThisPage = [...annotationMap.values()].filter(
(t) => t.def.page === currentPage,
);
annotationSection = `\nAnnotations: ${onThisPage.length} on this page, ${annotationMap.size} total`;
if (formFieldValues.size > 0) {
annotationSection += ` | ${formFieldValues.size} form field(s) filled`;
}
// List annotations on current page with their coordinates (in model space)
if (onThisPage.length > 0) {
annotationSection +=
"\nAnnotations on this page (visible in screenshot):";
for (const t of onThisPage) {
const d = convertToModelCoords(t.def, pageHeightPt);
const selected = selectedAnnotationIds.has(d.id) ? " (SELECTED)" : "";
if ("rects" in d && d.rects.length > 0) {
const r = d.rects[0];
annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(r.x)},${Math.round(r.y)}) ${Math.round(r.width)}x${Math.round(r.height)}${selected}`;
} else if ("x" in d && "y" in d) {
annotationSection += `\n [${d.id}] ${d.type} at (${Math.round(d.x)},${Math.round(d.y)})${selected}`;
}
}
}
}
// Include focused field or selected annotation info
let focusSection = "";
if (selectedAnnotationIds.size > 0) {
const ids = [...selectedAnnotationIds];
const descs = ids.map((selId) => {
const tracked = annotationMap.get(selId);
if (!tracked) return selId;
return `[${selId}] (${tracked.def.type})`;
});
focusSection = `\nSelected: ${descs.join(", ")}`;
}
if (focusedFieldName) {
const label = getFormFieldLabel(focusedFieldName);
const value = formFieldValues.get(focusedFieldName);
focusSection += `\nFocused field: "${label}" (name="${focusedFieldName}")`;
if (value !== undefined) {
focusSection += ` = ${JSON.stringify(value)}`;
}
}
const contextText = `${header}${searchSection}${annotationSection}${focusSection}\n\nPage content:\n${content}`;
// Build content array with text and optional screenshot
const contentBlocks: ContentBlock[] = [{ type: "text", text: contextText }];
// Add screenshot if host supports image content
if (app.getHostCapabilities()?.updateModelContext?.image) {
try {
// Render offscreen with ENABLE_STORAGE so filled form fields are visible
const base64Data = await renderPageOffscreen(currentPage);
if (base64Data) {
contentBlocks.push({
type: "image",
data: base64Data,
mimeType: "image/jpeg",
});
log.info("Added screenshot to model context");
}
} catch (err) {
log.info("Failed to capture screenshot:", err);
}
}
app.updateModelContext({ content: contentBlocks });
} catch (err) {
log.error("Error updating context:", err);
}
}
// =============================================================================
// Annotation Rendering
// =============================================================================
/**
* Convert PDF coordinates (bottom-left origin) to screen coordinates
* relative to the page wrapper. PDF.js viewport handles rotation and scale.
*/
function pdfRectToScreen(
rect: Rect,
viewport: { width: number; height: number; scale: number },
): { left: number; top: number; width: number; height: number } {
const s = viewport.scale;
// PDF origin is bottom-left, screen origin is top-left
const left = rect.x * s;
const top = viewport.height - (rect.y + rect.height) * s;
const width = rect.width * s;
const height = rect.height * s;
return { left, top, width, height };
}
function pdfPointToScreen(
x: number,
y: number,
viewport: { width: number; height: number; scale: number },
): { left: number; top: number } {
const s = viewport.scale;
return { left: x * s, top: viewport.height - y * s };
}
/** Convert a screen-space delta (pixels) to a PDF-space delta. */
function screenToPdfDelta(dx: number, dy: number): { dx: number; dy: number } {
return { dx: dx / scale, dy: -dy / scale };
}
// =============================================================================
// Undo / Redo
// =============================================================================
function pushEdit(entry: EditEntry): void {
undoStack.push(entry);
redoStack.length = 0;
}
function undo(): void {
const entry = undoStack.pop();
if (!entry) return;
redoStack.push(entry);
applyEdit(entry, true);
}
function redo(): void {
const entry = redoStack.pop();
if (!entry) return;
undoStack.push(entry);
applyEdit(entry, false);
}
function applyEdit(entry: EditEntry, reverse: boolean): void {
const state = reverse ? entry.before : entry.after;
if (entry.type === "add") {
if (reverse) {
removeAnnotation(entry.id, true);
} else {
addAnnotation(state!, true);
}
} else if (entry.type === "remove") {
if (reverse) {
addAnnotation(state!, true);
} else {
removeAnnotation(entry.id, true);
}
} else {
if (state) {
const tracked = annotationMap.get(entry.id);
if (tracked) {
tracked.def = { ...state };
} else {
annotationMap.set(entry.id, { def: { ...state }, elements: [] });
}
}
renderAnnotationsForPage(currentPage);
renderAnnotationPanel();
}
persistAnnotations();
}
// =============================================================================
// Selection
// =============================================================================
/**
* Select annotation(s). Pass null to deselect all.
* If additive is true, toggle the given id without clearing existing selection.
*/
function selectAnnotation(id: string | null, additive = false): void {
if (!additive) {
// Clear all existing selection visuals
for (const prevId of selectedAnnotationIds) {
const tracked = annotationMap.get(prevId);
if (tracked) {
for (const el of tracked.elements) {
el.classList.remove("annotation-selected");
}
}
}
// Remove handles
for (const h of annotationLayerEl.querySelectorAll(
".annotation-handle, .annotation-handle-rotate",
)) {
h.remove();
}
selectedAnnotationIds.clear();
}
if (id) {
if (additive && selectedAnnotationIds.has(id)) {
// Toggle off
selectedAnnotationIds.delete(id);
const tracked = annotationMap.get(id);
if (tracked) {
for (const el of tracked.elements) {
el.classList.remove("annotation-selected");
}
}
} else {
selectedAnnotationIds.add(id);
}
}
// Apply selection visuals + handles on all selected
// Only show handles when exactly one annotation is selected
for (const selId of selectedAnnotationIds) {
const tracked = annotationMap.get(selId);
if (tracked) {
for (const el of tracked.elements) {
el.classList.add("annotation-selected");
}
if (selectedAnnotationIds.size === 1) {
showHandles(tracked);
}
}
}
// Auto-expand the accordion section for the selected annotation's page
if (id) {
const tracked = annotationMap.get(id);
if (tracked) {
panelState.openAccordionSection = `page-${tracked.def.page}`;
}
}
// Re-render the panel so accordion sections open/close to match selection
renderAnnotationPanel();
// Scroll the selected card into view in the sidebar
if (id) {
const card = annotationsPanelListEl.querySelector(
`.annotation-card[data-annotation-id="${id}"]`,
);
if (card) {
card.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
// Sync sidebar
syncSidebarSelection();
// Auto-dock floating panel away from selected annotation
if (
selectedAnnotationIds.size > 0 &&
annotationsPanelEl.classList.contains("floating") &&
panelState.open
) {
autoDockPanel();
}
// Update model context with selection info
updatePageContext();
}
/** Types that support resize handles (need width/height). */
const RESIZABLE_TYPES = new Set(["rectangle", "circle", "image"]);
/** Types that support rotation. */
const ROTATABLE_TYPES = new Set(["rectangle", "stamp", "image"]);
function showHandles(tracked: TrackedAnnotation): void {
const def = tracked.def;
if (tracked.elements.length === 0) return;
if (!RESIZABLE_TYPES.has(def.type) && !ROTATABLE_TYPES.has(def.type)) return;
const el = tracked.elements[0];
// Resize handles (corners) for types with width/height
if (RESIZABLE_TYPES.has(def.type) && "width" in def && "height" in def) {
for (const corner of ["nw", "ne", "sw", "se"] as const) {
const handle = document.createElement("div");
handle.className = `annotation-handle ${corner}`;
handle.dataset.corner = corner;
const isImagePreserve =
def.type === "image" &&
((def as ImageAnnotation).aspect ?? "preserve") === "preserve";
handle.title = isImagePreserve
? "Drag to resize (Shift for free resize)"
: "Drag to resize (Shift to keep proportions)";
setupResizeHandle(handle, tracked, corner);
el.appendChild(handle);
}
}
// Rotate handle for rotatable types
if (ROTATABLE_TYPES.has(def.type)) {
const handle = document.createElement("div");
handle.className = "annotation-handle-rotate";
handle.title = "Drag to rotate";
setupRotateHandle(handle, tracked);
el.appendChild(handle);
}
}
// =============================================================================
// Drag (move)
// =============================================================================
const DRAGGABLE_TYPES = new Set([
"rectangle",
"circle",
"line",
"freetext",
"stamp",
"note",
"image",
// "imported" is draggable in the UI but the move does NOT persist to the
// PDF on save (addAnnotationDicts skips it). Resize/rotate stay disabled
// — the appearance bitmap would just stretch.
"imported",
]);
function setupAnnotationInteraction(
el: HTMLElement,
tracked: TrackedAnnotation,
): void {
// Click to select (Shift+click for additive multi-select)
el.addEventListener("mousedown", (e) => {
// Ignore if clicking on a handle
if (
(e.target as HTMLElement).classList.contains("annotation-handle") ||
(e.target as HTMLElement).classList.contains("annotation-handle-rotate")
) {
return;
}
e.stopPropagation();
selectAnnotation(tracked.def.id, e.shiftKey);
// Start drag for draggable types (only single-select)
if (DRAGGABLE_TYPES.has(tracked.def.type) && !e.shiftKey) {
startDrag(e, tracked);
}
});
// Double-click to send message to modify annotation (same as sidebar card)
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
selectAnnotation(tracked.def.id);
const label = getAnnotationLabel(tracked.def);
const previewText = getAnnotationPreview(tracked.def);
const desc = previewText ? `${label}: ${previewText}` : label;
void app
.sendMessage({
role: "user",
content: [{ type: "text", text: `update ${desc}: ` }],
})
.catch(log.error);
});
}
function startDrag(e: MouseEvent, tracked: TrackedAnnotation): void {
const def = tracked.def;
const startX = e.clientX;
const startY = e.clientY;
const beforeDef = { ...def } as PdfAnnotationDef;
let moved = false;
// Store original element positions
const originalPositions = tracked.elements.map((el) => ({
left: parseFloat(el.style.left),
top: parseFloat(el.style.top),
}));
document.body.style.cursor = "grabbing";
for (const el of tracked.elements) {
el.classList.add("annotation-dragging");
}
const onMouseMove = (ev: MouseEvent) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
// Move elements directly for smooth feedback
for (let i = 0; i < tracked.elements.length; i++) {
tracked.elements[i].style.left = `${originalPositions[i].left + dx}px`;
tracked.elements[i].style.top = `${originalPositions[i].top + dy}px`;
}
};
const onMouseUp = (ev: MouseEvent) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
document.body.style.cursor = "";
for (const el of tracked.elements) {
el.classList.remove("annotation-dragging");
}
if (!moved) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const pdfDelta = screenToPdfDelta(dx, dy);
// Apply move to def
applyMoveToDef(
tracked.def as PdfAnnotationDef & { x: number; y: number },
pdfDelta.dx,
pdfDelta.dy,
);
const afterDef = { ...tracked.def } as PdfAnnotationDef;
pushEdit({
type: "update",
id: def.id,
before: beforeDef,
after: afterDef,
});
persistAnnotations();
// Re-render to get correct positions
renderAnnotationsForPage(currentPage);
// Re-select to show handles
selectAnnotation(def.id);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}
function applyMoveToDef(
def: PdfAnnotationDef & { x?: number; y?: number },
dx: number,
dy: number,
): void {
if (def.type === "line") {
def.x1 += dx;
def.y1 += dy;
def.x2 += dx;
def.y2 += dy;
} else if ("x" in def && "y" in def) {
def.x! += dx;
def.y! += dy;
}
}
// =============================================================================
// Resize (rectangle, circle, image)
// =============================================================================
function setupResizeHandle(
handle: HTMLElement,
tracked: TrackedAnnotation,
corner: "nw" | "ne" | "sw" | "se",
): void {
handle.addEventListener("mousedown", (e) => {
e.stopPropagation();
e.preventDefault();
const def = tracked.def as
| RectangleAnnotation
| CircleAnnotation
| ImageAnnotation;
const beforeDef = { ...def };
const startX = e.clientX;
const startY = e.clientY;
const aspectRatio = beforeDef.height / beforeDef.width;
const onMouseMove = (ev: MouseEvent) => {
const dxScreen = ev.clientX - startX;
const dyScreen = ev.clientY - startY;
const pdfD = screenToPdfDelta(dxScreen, dyScreen);
// Reset to before state then apply delta
let newX = beforeDef.x;
let newY = beforeDef.y;
let newW = beforeDef.width;
let newH = beforeDef.height;
// In PDF coords: x goes right, y goes up
if (corner.includes("w")) {
newX += pdfD.dx;
newW -= pdfD.dx;
} else {
newW += pdfD.dx;
}
if (corner.includes("s")) {
newY += pdfD.dy;
newH -= pdfD.dy;
} else {
newH += pdfD.dy;
}
// Constrain aspect ratio:
// - For images: preserve by default (Shift to ignore), unless aspect="ignore"
// - For other shapes: Shift to preserve
const isImage = def.type === "image";
const imageAspect = isImage
? ((def as ImageAnnotation).aspect ?? "preserve")
: undefined;
const constrainAspect = isImage
? imageAspect === "preserve"
? !ev.shiftKey // preserve by default, Shift to free-resize
: ev.shiftKey // ignore by default, Shift to constrain
: ev.shiftKey; // non-image: Shift to constrain
if (constrainAspect) {
// Use the wider dimension to drive the other
const candidateH = newW * aspectRatio;
newH = candidateH;
// Adjust origin for corners that anchor at bottom/left
if (corner.includes("s")) {
newY = beforeDef.y + beforeDef.height - newH;
}
if (corner.includes("w")) {
// width changed by resize, x was already adjusted above
}
}
// Enforce minimum size
if (newW < 5) {
newW = 5;
}
if (newH < 5) {
newH = 5;
}
def.x = newX;
def.y = newY;
def.width = newW;
def.height = newH;
// Re-render for live feedback
renderAnnotationsForPage(currentPage);
selectAnnotation(def.id);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const afterDef = { ...def };
pushEdit({
type: "update",
id: def.id,
before: beforeDef,
after: afterDef,
});
persistAnnotations();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
// =============================================================================
// Rotate (stamp, rectangle)
// =============================================================================
function setupRotateHandle(
handle: HTMLElement,
tracked: TrackedAnnotation,
): void {
handle.addEventListener("mousedown", (e) => {
e.stopPropagation();
e.preventDefault();
const def = tracked.def as
| StampAnnotation
| RectangleAnnotation
| ImageAnnotation;
const beforeDef = { ...def };
const el = tracked.elements[0];
const rect = el.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const onMouseMove = (ev: MouseEvent) => {
const angle = Math.atan2(ev.clientY - centerY, ev.clientX - centerX);
// Convert to degrees, offset so 0 = pointing up
let degrees = (angle * 180) / Math.PI + 90;
// Normalize
if (degrees < 0) degrees += 360;
if (degrees > 360) degrees -= 360;
// Snap to 15-degree increments when close
const snapped = Math.round(degrees / 15) * 15;
if (Math.abs(degrees - snapped) < 3) degrees = snapped;
def.rotation = Math.round(degrees);
renderAnnotationsForPage(currentPage);
selectAnnotation(def.id);
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const afterDef = { ...def };
pushEdit({
type: "update",
id: def.id,
before: beforeDef,
after: afterDef,
});
persistAnnotations();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
/**
* Paint annotations for a page onto a 2D canvas context.
* Used to include annotations in screenshots sent to the model.
*/
function paintAnnotationsOnCanvas(
ctx: CanvasRenderingContext2D,
pageNum: number,
viewport: { width: number; height: number; scale: number },
): void {
for (const tracked of annotationMap.values()) {
const def = tracked.def;
if (def.page !== pageNum) continue;
const color = getAnnotationColor(def);
switch (def.type) {
case "highlight":
ctx.save();
ctx.globalAlpha = 0.35;
ctx.fillStyle = def.color || "rgba(255, 255, 0, 1)";
for (const rect of def.rects) {
const s = pdfRectToScreen(rect, viewport);
ctx.fillRect(s.left, s.top, s.width, s.height);
}
ctx.restore();
break;
case "underline":
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
for (const rect of def.rects) {
const s = pdfRectToScreen(rect, viewport);
ctx.beginPath();
ctx.moveTo(s.left, s.top + s.height);
ctx.lineTo(s.left + s.width, s.top + s.height);
ctx.stroke();
}
ctx.restore();
break;
case "strikethrough":
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
for (const rect of def.rects) {
const s = pdfRectToScreen(rect, viewport);
const midY = s.top + s.height / 2;
ctx.beginPath();
ctx.moveTo(s.left, midY);
ctx.lineTo(s.left + s.width, midY);
ctx.stroke();
}
ctx.restore();
break;
case "note": {
const pos = pdfPointToScreen(def.x, def.y, viewport);
ctx.save();
ctx.fillStyle = color;
ctx.globalAlpha = 0.8;
ctx.fillRect(pos.left, pos.top - 16, 16, 16);
ctx.restore();
break;
}
case "rectangle": {
const s = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
ctx.save();
if (def.rotation) {
const cx = s.left + s.width / 2;
const cy = s.top + s.height / 2;
ctx.translate(cx, cy);
ctx.rotate((def.rotation * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
if (def.fillColor) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = def.fillColor;
ctx.fillRect(s.left, s.top, s.width, s.height);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(s.left, s.top, s.width, s.height);
ctx.restore();
break;
}
case "freetext": {
const pos = pdfPointToScreen(def.x, def.y, viewport);
ctx.save();
ctx.fillStyle = color;
ctx.font = `${(def.fontSize || 12) * viewport.scale}px Helvetica, Arial, sans-serif`;
ctx.fillText(def.content, pos.left, pos.top);
ctx.restore();
break;
}
case "stamp": {
const pos = pdfPointToScreen(def.x, def.y, viewport);
ctx.save();
ctx.translate(pos.left, pos.top);
if (def.rotation) ctx.rotate((def.rotation * Math.PI) / 180);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = 3;
ctx.globalAlpha = 0.6;
ctx.font = `bold ${24 * viewport.scale}px Helvetica, Arial, sans-serif`;
const metrics = ctx.measureText(def.label);
const pad = 8 * viewport.scale;
ctx.strokeRect(
-pad,
-24 * viewport.scale - pad,
metrics.width + pad * 2,
24 * viewport.scale + pad * 2,
);
ctx.fillText(def.label, 0, 0);
ctx.restore();
break;
}
case "circle": {
const s = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
ctx.save();
if (def.fillColor) {
ctx.globalAlpha = 0.3;
ctx.fillStyle = def.fillColor;
ctx.beginPath();
ctx.ellipse(
s.left + s.width / 2,
s.top + s.height / 2,
s.width / 2,
s.height / 2,
0,
0,
Math.PI * 2,
);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(
s.left + s.width / 2,
s.top + s.height / 2,
s.width / 2,
s.height / 2,
0,
0,
Math.PI * 2,
);
ctx.stroke();
ctx.restore();
break;
}
case "line": {
const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(p1.left, p1.top);
ctx.lineTo(p2.left, p2.top);
ctx.stroke();
ctx.restore();
break;
}
case "image": {
const s = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
// Try to draw from cache
const cachedImg = imageCache.get(def.id);
if (cachedImg) {
ctx.save();
if (def.rotation) {
const cx = s.left + s.width / 2;
const cy = s.top + s.height / 2;
ctx.translate(cx, cy);
ctx.rotate((def.rotation * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
ctx.drawImage(cachedImg, s.left, s.top, s.width, s.height);
ctx.restore();
} else {
// Load image asynchronously into cache for next paint
const src = safeImageSrc(def);
if (src) {
const img = new Image();
img.onload = () => {
imageCache.set(def.id, img);
};
img.src = src;
}
// Draw placeholder border
ctx.save();
ctx.strokeStyle = "#999";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(s.left, s.top, s.width, s.height);
ctx.restore();
}
break;
}
case "imported": {
const s = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
const bmp = annotationCanvasMap.get(def.pdfjsId);
ctx.save();
if (bmp) {
ctx.drawImage(bmp, s.left, s.top, s.width, s.height);
} else {
ctx.strokeStyle = "#666";
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.strokeRect(s.left, s.top, s.width, s.height);
}
ctx.restore();
break;
}
}
}
}
function renderAnnotationsForPage(pageNum: number): void {
// Clear existing annotation elements
annotationLayerEl.innerHTML = "";
// Remove tracked element refs for all annotations
for (const tracked of annotationMap.values()) {
tracked.elements = [];
}
if (!pdfDocument) return;
// Get viewport for coordinate conversion
const vp = {
width: parseFloat(annotationLayerEl.style.width) || 0,
height: parseFloat(annotationLayerEl.style.height) || 0,
scale,
};
if (vp.width === 0 || vp.height === 0) return;
for (const tracked of annotationMap.values()) {
const def = tracked.def;
if (def.page !== pageNum) continue;
const elements = renderAnnotation(def, vp);
tracked.elements = elements;
for (const el of elements) {
// Set up selection + drag/resize/rotate interactions
setupAnnotationInteraction(el, tracked);
annotationLayerEl.appendChild(el);
}
// Restore selection state after re-render
if (selectedAnnotationIds.has(def.id)) {
for (const el of elements) {
el.classList.add("annotation-selected");
}
if (selectedAnnotationIds.size === 1) {
showHandles(tracked);
}
}
}
// Refresh panel to update current-page highlighting
renderAnnotationPanel();
}
function renderAnnotation(
def: PdfAnnotationDef,
viewport: { width: number; height: number; scale: number },
): HTMLElement[] {
switch (def.type) {
case "highlight": {
// Force translucency: def.color is an opaque hex (e.g. "#ffff00"), which
// would override the rgba()/mix-blend-mode in CSS and hide the text.
const rgb = def.color ? cssColorToRgb(def.color) : null;
const bg = rgb
? `rgba(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)}, 0.35)`
: undefined;
return renderRectsAnnotation(
def.rects,
"annotation-highlight",
viewport,
bg ? { background: bg } : {},
);
}
case "underline":
return renderRectsAnnotation(
def.rects,
"annotation-underline",
viewport,
def.color ? { borderBottomColor: def.color } : {},
);
case "strikethrough":
return renderRectsAnnotation(
def.rects,
"annotation-strikethrough",
viewport,
{},
def.color,
);
case "note":
return [renderNoteAnnotation(def, viewport)];
case "rectangle":
return [renderRectangleAnnotation(def, viewport)];
case "freetext":
return [renderFreetextAnnotation(def, viewport)];
case "stamp":
return [renderStampAnnotation(def, viewport)];
case "circle":
return [renderCircleAnnotation(def, viewport)];
case "line":
return [renderLineAnnotation(def, viewport)];
case "image":
return [renderImageAnnotation(def, viewport)];
case "imported":
return [renderImportedAnnotation(def, viewport)];
}
}
function renderRectsAnnotation(
rects: Rect[],
className: string,
viewport: { width: number; height: number; scale: number },
extraStyles: Record,
strikeColor?: string,
): HTMLElement[] {
return rects.map((rect) => {
const screen = pdfRectToScreen(rect, viewport);
const el = document.createElement("div");
el.className = className;
el.style.left = `${screen.left}px`;
el.style.top = `${screen.top}px`;
el.style.width = `${screen.width}px`;
el.style.height = `${screen.height}px`;
for (const [k, v] of Object.entries(extraStyles)) {
(el.style as unknown as Record)[k] = v;
}
if (strikeColor) {
// Set color for the ::after pseudo-element via CSS custom property
el.style.setProperty("--strike-color", strikeColor);
el.querySelector("::after"); // no-op, style via CSS instead
// Actually use inline style on a child element for the line
const line = document.createElement("div");
line.style.position = "absolute";
line.style.left = "0";
line.style.right = "0";
line.style.top = "50%";
line.style.borderTop = `2px solid ${strikeColor}`;
el.appendChild(line);
}
return el;
});
}
function renderNoteAnnotation(
def: NoteAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const pos = pdfPointToScreen(def.x, def.y, viewport);
const el = document.createElement("div");
el.className = "annotation-note";
el.style.left = `${pos.left}px`;
el.style.top = `${pos.top - 20}px`; // offset up so note icon is at the point
if (def.color) el.style.color = def.color;
const tooltip = document.createElement("div");
tooltip.className = "annotation-tooltip";
tooltip.textContent = def.content;
el.appendChild(tooltip);
return el;
}
function renderRectangleAnnotation(
def: RectangleAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const screen = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
const el = document.createElement("div");
el.className = "annotation-rectangle";
el.style.left = `${screen.left}px`;
el.style.top = `${screen.top}px`;
el.style.width = `${screen.width}px`;
el.style.height = `${screen.height}px`;
if (def.color) el.style.borderColor = def.color;
if (def.fillColor) el.style.backgroundColor = def.fillColor;
if (def.rotation) {
el.style.transform = `rotate(${def.rotation}deg)`;
el.style.transformOrigin = "center center";
}
return el;
}
function renderFreetextAnnotation(
def: FreetextAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const pos = pdfPointToScreen(def.x, def.y, viewport);
const el = document.createElement("div");
el.className = "annotation-freetext";
el.style.left = `${pos.left}px`;
el.style.top = `${pos.top}px`;
el.style.fontSize = `${(def.fontSize || 12) * viewport.scale}px`;
if (def.color) el.style.color = def.color;
el.textContent = def.content;
return el;
}
function renderStampAnnotation(
def: StampAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const pos = pdfPointToScreen(def.x, def.y, viewport);
const el = document.createElement("div");
el.className = "annotation-stamp";
el.style.left = `${pos.left}px`;
el.style.top = `${pos.top}px`;
el.style.fontSize = `${24 * viewport.scale}px`;
if (def.color) el.style.color = def.color;
if (def.rotation) {
el.style.transform = `rotate(${def.rotation}deg)`;
el.style.transformOrigin = "center center";
}
el.textContent = def.label;
return el;
}
function renderCircleAnnotation(
def: CircleAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const screen = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
const el = document.createElement("div");
el.className = "annotation-circle";
el.style.left = `${screen.left}px`;
el.style.top = `${screen.top}px`;
el.style.width = `${screen.width}px`;
el.style.height = `${screen.height}px`;
if (def.color) el.style.borderColor = def.color;
if (def.fillColor) el.style.backgroundColor = def.fillColor;
return el;
}
function renderLineAnnotation(
def: LineAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const p1 = pdfPointToScreen(def.x1, def.y1, viewport);
const p2 = pdfPointToScreen(def.x2, def.y2, viewport);
const dx = p2.left - p1.left;
const dy = p2.top - p1.top;
const length = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
const el = document.createElement("div");
el.className = "annotation-line";
el.style.left = `${p1.left}px`;
el.style.top = `${p1.top}px`;
el.style.width = `${length}px`;
el.style.transform = `rotate(${angle}rad)`;
el.style.transformOrigin = "0 0";
if (def.color) el.style.borderColor = def.color;
return el;
}
function renderImageAnnotation(
def: ImageAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const screen = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
const el = document.createElement("div");
el.className = "annotation-image";
el.style.left = `${screen.left}px`;
el.style.top = `${screen.top}px`;
el.style.width = `${screen.width}px`;
el.style.height = `${screen.height}px`;
if (def.rotation) {
el.style.transform = `rotate(${def.rotation}deg)`;
el.style.transformOrigin = "center center";
}
const imgSrc = safeImageSrc(def);
if (imgSrc) {
const img = document.createElement("img");
img.src = imgSrc;
img.style.width = "100%";
img.style.height = "100%";
img.style.display = "block";
img.style.pointerEvents = "none";
img.draggable = false;
el.appendChild(img);
}
return el;
}
/**
* Per-annotation appearance bitmaps from page.render(). Keyed by pdf.js
* annotation id (e.g. "118R"). Populated for the current page only —
* cleared at the start of each renderPage().
*/
const annotationCanvasMap = new Map();
function renderImportedAnnotation(
def: ImportedAnnotation,
viewport: { width: number; height: number; scale: number },
): HTMLElement {
const screen = pdfRectToScreen(
{ x: def.x, y: def.y, width: def.width, height: def.height },
viewport,
);
const el = document.createElement("div");
el.className = "annotation-imported";
el.style.left = `${screen.left}px`;
el.style.top = `${screen.top}px`;
el.style.width = `${screen.width}px`;
el.style.height = `${screen.height}px`;
el.title = `${def.subtype} (from PDF)`;
// page.render() may or may not have produced a separate canvas for this
// annotation (hasOwnCanvas depends on the PDF's flags). When it did, use
// it as a pixel-faithful body; when it didn't, the appearance is on the
// main canvas already, so leave the box transparent — it still captures
// clicks for select/delete.
const canvas = annotationCanvasMap.get(def.pdfjsId);
if (canvas) {
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.display = "block";
canvas.style.pointerEvents = "none";
el.appendChild(canvas);
}
return el;
}
// =============================================================================
// Annotation CRUD
// =============================================================================
function addAnnotation(def: PdfAnnotationDef, skipUndo = false): void {
// Remove existing if same id (without pushing to undo)
removeAnnotation(def.id, true);
annotationMap.set(def.id, { def, elements: [] });
if (!skipUndo) {
pushEdit({ type: "add", id: def.id, before: null, after: { ...def } });
}
// Re-render if on current page
if (def.page === currentPage) {
renderAnnotationsForPage(currentPage);
}
updateAnnotationsBadge();
renderAnnotationPanel();
}
function updateAnnotation(
update: Partial & { id: string; type: string },
skipUndo = false,
): void {
const tracked = annotationMap.get(update.id);
if (!tracked) return;
const before = { ...tracked.def } as PdfAnnotationDef;
// Merge partial update into existing def
const merged = { ...tracked.def, ...update } as PdfAnnotationDef;
tracked.def = merged;
if (!skipUndo) {
pushEdit({ type: "update", id: update.id, before, after: { ...merged } });
}
// Re-render if on current page
if (merged.page === currentPage) {
renderAnnotationsForPage(currentPage);
}
renderAnnotationPanel();
}
function removeAnnotation(id: string, skipUndo = false): void {
const tracked = annotationMap.get(id);
if (!tracked) return;
if (!skipUndo) {
pushEdit({ type: "remove", id, before: { ...tracked.def }, after: null });
}
for (const el of tracked.elements) el.remove();
annotationMap.delete(id);
selectedAnnotationIds.delete(id);
updateAnnotationsBadge();
renderAnnotationPanel();
}
// =============================================================================
// Annotation Panel → extracted to ./annotation-panel.ts
// =============================================================================
// =============================================================================
// highlight_text Command
// =============================================================================
function handleHighlightText(cmd: {
id: string;
query: string;
page?: number;
color?: string;
content?: string;
}): void {
const pagesToSearch: number[] = [];
if (cmd.page) {
pagesToSearch.push(cmd.page);
} else {
// Search all pages that have cached text
for (const [pageNum, text] of pageTextCache) {
if (text.toLowerCase().includes(cmd.query.toLowerCase())) {
pagesToSearch.push(pageNum);
}
}
}
let annotationIndex = 0;
for (const pageNum of pagesToSearch) {
// Find text positions using the text layer DOM if on current page,
// otherwise create approximate rects from text cache positions
const rects = findTextRects(cmd.query, pageNum);
if (rects.length > 0) {
const id =
pagesToSearch.length > 1
? `${cmd.id}_p${pageNum}_${annotationIndex++}`
: cmd.id;
addAnnotation({
type: "highlight",
id,
page: pageNum,
rects,
color: cmd.color,
content: cmd.content,
});
}
}
}
/**
* Find text in a page and return PDF-coordinate rects.
* Uses the TextLayer DOM when the page is currently rendered,
* otherwise falls back to approximate character-based positioning.
*/
function findTextRects(query: string, pageNum: number): Rect[] {
if (pageNum !== currentPage) {
// For non-current pages, create approximate rects from page dimensions
// The text will be properly positioned when the user navigates to that page
return findTextRectsFromCache(query, pageNum);
}
// Use text layer DOM for current page
const spans = Array.from(
textLayerEl.querySelectorAll("span"),
) as HTMLElement[];
if (spans.length === 0) return findTextRectsFromCache(query, pageNum);
const lowerQuery = query.toLowerCase();
const rects: Rect[] = [];
const wrapperEl = textLayerEl.parentElement!;
const wrapperRect = wrapperEl.getBoundingClientRect();
for (const span of spans) {
const text = span.textContent || "";
if (text.length === 0) continue;
const lowerText = text.toLowerCase();
let pos = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, pos);
if (idx === -1) break;
pos = idx + 1;
const textNode = span.firstChild;
if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue;
try {
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, Math.min(idx + lowerQuery.length, text.length));
const clientRects = range.getClientRects();
for (let ri = 0; ri < clientRects.length; ri++) {
const r = clientRects[ri];
// Convert screen coords back to PDF coords
const screenLeft = r.left - wrapperRect.left;
const screenTop = r.top - wrapperRect.top;
const pdfX = screenLeft / scale;
const pdfHeight = r.height / scale;
const pdfWidth = r.width / scale;
const pageHeight = parseFloat(annotationLayerEl.style.height) / scale;
const pdfY = pageHeight - (screenTop + r.height) / scale;
rects.push({
x: pdfX,
y: pdfY,
width: pdfWidth,
height: pdfHeight,
});
}
} catch {
// Range API errors with stale nodes
}
}
}
return rects;
}
function findTextRectsFromCache(query: string, pageNum: number): Rect[] {
const text = pageTextCache.get(pageNum);
if (!text) return [];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const idx = lowerText.indexOf(lowerQuery);
if (idx === -1) return [];
// Text exists in the cache but the text-layer DOM for this page isn't
// rendered yet — we can't compute accurate rects. Returning a placeholder
// would persist wrong coordinates; return empty and let the caller skip.
return [];
}
// =============================================================================
// get_pages — Offscreen rendering for model analysis
// =============================================================================
const MAX_GET_PAGES = 20;
const SCREENSHOT_MAX_DIM = 768; // Max pixel dimension for screenshots
/**
* Expand intervals into a sorted deduplicated list of page numbers,
* clamped to [1, totalPages].
*/
function expandIntervals(
intervals: Array<{ start?: number; end?: number }>,
): number[] {
const pages = new Set();
for (const iv of intervals) {
const s = Math.max(1, iv.start ?? 1);
const e = Math.min(totalPages, iv.end ?? totalPages);
for (let p = s; p <= e; p++) pages.add(p);
}
return [...pages].sort((a, b) => a - b);
}
/**
* Render a single page to an offscreen canvas and return base64 JPEG.
* Does not affect the visible canvas or text layer.
*/
async function renderPageOffscreen(pageNum: number): Promise {
if (!pdfDocument) throw new Error("No PDF loaded");
const page = await pdfDocument.getPage(pageNum);
const baseViewport = page.getViewport({ scale: 1.0 });
// Scale down to fit within SCREENSHOT_MAX_DIM
const maxDim = Math.max(baseViewport.width, baseViewport.height);
const renderScale =
maxDim > SCREENSHOT_MAX_DIM ? SCREENSHOT_MAX_DIM / maxDim : 1.0;
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement("canvas");
const dpr = 1; // No retina scaling for model screenshots
canvas.width = viewport.width * dpr;
canvas.height = viewport.height * dpr;
const ctx = canvas.getContext("2d")!;
ctx.scale(dpr, dpr);
// Render with ENABLE_STORAGE so filled form fields appear on the canvas
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (page.render as any)({
canvasContext: ctx,
viewport,
annotationMode: AnnotationMode.ENABLE_STORAGE,
annotationStorage: pdfDocument.annotationStorage,
}).promise;
// Paint annotations on top so the model can see them
paintAnnotationsOnCanvas(ctx, pageNum, {
width: viewport.width,
height: viewport.height,
scale: renderScale,
});
// Extract base64 JPEG (much smaller than PNG, well within body limits)
const dataUrl = canvas.toDataURL("image/jpeg", 0.85);
return dataUrl.split(",")[1];
}
/**
* Snapshot the live viewer for `interact({action:"get_viewer_state"})`.
*
* Selection is read from `window.getSelection()` at call time — no caching;
* if the user navigated away or nothing is selected, `selection` is `null`.
* `boundingRect` is in model coords (PDF points, origin top-left, y-down) so
* it can be fed straight back into `add_annotations`.
*/
async function handleGetViewerState(requestId: string): Promise {
const CONTEXT_CHARS = 200;
let selection: {
text: string;
contextBefore: string;
contextAfter: string;
boundingRect: { x: number; y: number; width: number; height: number };
} | null = null;
const sel = window.getSelection();
const selectedText = sel?.toString().replace(/\s+/g, " ").trim();
if (sel && selectedText && sel.rangeCount > 0) {
// Only treat it as a PDF selection if it lives inside the text layer of
// the rendered page (not the toolbar, search box, etc.).
const range = sel.getRangeAt(0);
const anchor =
range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? (range.commonAncestorContainer as Element)
: range.commonAncestorContainer.parentElement;
if (anchor && textLayerEl.contains(anchor)) {
// Context: locate selection in the page's extracted text and slice
// ±CONTEXT_CHARS around it. Falls back to empty strings if fuzzy
// match fails (still return text + rect — they're the load-bearing
// bits).
const pageText = pageTextCache.get(currentPage) ?? "";
const loc = findSelectionInText(pageText, selectedText);
const contextBefore = loc
? pageText.slice(Math.max(0, loc.start - CONTEXT_CHARS), loc.start)
: "";
const contextAfter = loc
? pageText.slice(loc.end, loc.end + CONTEXT_CHARS)
: "";
// Single bounding box, page-relative model coords. getBoundingClientRect
// is viewport-relative; subtract the page-wrapper origin then divide by
// scale → PDF points (top-left origin, y-down — matches the coord
// system documented in the interact tool description).
const r = range.getBoundingClientRect();
const origin = pageWrapperEl.getBoundingClientRect();
const round = (n: number) => Math.round(n * 100) / 100;
selection = {
text: selectedText,
contextBefore,
contextAfter,
boundingRect: {
x: round((r.left - origin.left) / scale),
y: round((r.top - origin.top) / scale),
width: round(r.width / scale),
height: round(r.height / scale),
},
};
}
}
const state = {
currentPage,
pageCount: totalPages,
zoom: Math.round(scale * 100),
displayMode: currentDisplayMode,
selectedAnnotationIds: [...selectedAnnotationIds],
selection,
};
await app.callServerTool({
name: "submit_viewer_state",
arguments: { requestId, state: JSON.stringify(state, null, 2) },
});
}
/**
* Collect text and/or screenshots for a set of page intervals.
* Shared by the server-driven `get_pages` command (via handleGetPages)
* and the app-registered `get_text` / `get_screenshot` tools.
*/
async function collectPageData(
intervals: Array<{ start?: number; end?: number }>,
getText: boolean,
getScreenshots: boolean,
): Promise> {
const allPages = expandIntervals(intervals);
const pages = allPages.slice(0, MAX_GET_PAGES);
log.info(
`collectPageData: ${pages.length} pages (${pages[0]}..${pages[pages.length - 1]}), text=${getText}, screenshots=${getScreenshots}`,
);
const results: Array<{
page: number;
text?: string;
image?: string;
}> = [];
for (const pageNum of pages) {
const entry: { page: number; text?: string; image?: string } = {
page: pageNum,
};
if (getText) {
// Use cached text if available, otherwise extract on the fly
let text = pageTextCache.get(pageNum);
if (text == null && pdfDocument) {
try {
const pg = await pdfDocument.getPage(pageNum);
const tc = await pg.getTextContent();
text = (tc.items as Array<{ str?: string }>)
.map((item) => item.str || "")
.join(" ");
pageTextCache.set(pageNum, text);
} catch (err) {
log.error(
`collectPageData: text extraction failed for page ${pageNum}:`,
err,
);
text = "";
}
}
entry.text = text ?? "";
}
if (getScreenshots) {
try {
entry.image = await renderPageOffscreen(pageNum);
} catch (err) {
log.error(
`collectPageData: screenshot failed for page ${pageNum}:`,
err,
);
}
}
results.push(entry);
}
return results;
}
async function handleGetPages(cmd: {
requestId: string;
intervals: Array<{ start?: number; end?: number }>;
getText: boolean;
getScreenshots: boolean;
}): Promise {
const results = await collectPageData(
cmd.intervals,
cmd.getText,
cmd.getScreenshots,
);
// Submit results back to server
try {
await app.callServerTool({
name: "submit_page_data",
arguments: { requestId: cmd.requestId, pages: results },
});
log.info(
`get_pages: submitted ${results.length} page(s) for ${cmd.requestId}`,
);
} catch (err) {
log.error("get_pages: failed to submit results:", err);
}
}
// =============================================================================
// Annotation Persistence
// =============================================================================
/** Storage key for annotations — uses toolInfo.id (available early) with viewUUID fallback */
function annotationStorageKey(): string | null {
const toolId = app.getHostContext()?.toolInfo?.id;
if (toolId) return `pdf-annot:${toolId}`;
if (viewUUID) return `${viewUUID}:annotations`;
return null;
}
/**
* Import one page's native annotations into the baseline. Called lazily from
* renderPage() so we don't walk every page (and pull most of the file via
* range requests) before the user sees anything. Idempotent per page.
*/
function scanPageBaselineAnnotations(
pageNum: number,
annotations: unknown[],
): void {
if (baselineScannedPages.has(pageNum)) return;
baselineScannedPages.add(pageNum);
let imported = 0;
for (let i = 0; i < annotations.length; i++) {
// Isolate each annotation: a malformed one must not bubble up to the
// caller's form-layer try in renderPage() (which would skip
// AnnotationLayer.render and hide form widgets for the whole page).
try {
const ann = annotations[i] as {
annotationType?: number;
subtype?: string;
name?: string;
rect?: number[];
};
const def = importPdfjsAnnotation(ann, pageNum, i);
if (def) {
pdfBaselineAnnotations.push(def);
imported++;
if (!annotationMap.has(def.id) && !restoredRemovedIds.has(def.id)) {
annotationMap.set(def.id, { def, elements: [] });
}
} else if (ann.annotationType !== 20) {
// Widget (type 20) is expected to be skipped; anything else we
// don't import will still be painted by page.render() onto the
// canvas as unselectable pixels. Log so we can diagnose
// "ghost annotations" (visible but not in panel, not clickable).
log.info(
`[WARN] Baseline: skipped PDF annotation on page ${pageNum}`,
`type=${ann.annotationType}`,
`subtype=${ann.subtype ?? "?"}`,
`name=${ann.name ?? "?"}`,
`rect=${ann.rect ? JSON.stringify(ann.rect) : "none"}`,
);
}
} catch (err) {
log.info(`Baseline: page ${pageNum} annotation import failed`, err);
}
}
if (imported > 0) {
try {
updateAnnotationsBadge();
renderAnnotationPanel();
} catch (err) {
log.info(`Baseline: page ${pageNum} panel update failed`, err);
}
}
}
function persistAnnotations(): void {
// Compute diff relative to PDF baseline
const currentAnnotations: PdfAnnotationDef[] = [];
for (const tracked of annotationMap.values()) {
currentAnnotations.push(tracked.def);
}
const diff = computeDiff(
pdfBaselineAnnotations,
currentAnnotations,
formFieldValues,
pdfBaselineFormValues,
);
// computeDiff only sees baseline ids from pages we've already scanned.
// Carry forward restored tombstones for unvisited pages so the first
// persist after restore doesn't drop them. Once every page is scanned the
// baseline is complete and computeDiff is authoritative on its own —
// dropping the carry-forward then also stops a stale id (no longer in the
// file) from pinning dirty=true forever.
if (baselineScannedPages.size < totalPages) {
for (const id of restoredRemovedIds) {
if (!annotationMap.has(id) && !diff.removed.includes(id)) {
diff.removed.push(id);
}
}
}
// Dirty tracks whether there are unsaved changes. Undoing back to baseline
// yields an empty diff → clean again → save button disables.
if (!isRestoring) setDirty(!isDiffEmpty(diff));
const key = annotationStorageKey();
if (!key) return;
try {
localStorage.setItem(key, serializeDiff(diff));
} catch {
// localStorage may be full or unavailable
}
}
function restoreAnnotations(): void {
const key = annotationStorageKey();
if (!key) return;
isRestoring = true;
try {
const raw = localStorage.getItem(key);
if (!raw) return;
// Try new diff-based format first
const diff = deserializeDiff(raw);
// Merge baseline + diff. The loop below is add-only, so we MUST also
// delete: the per-page baseline scan re-seeds annotationMap with every
// native id it encounters — including ones in diff.removed. Without the
// deletes here AND the restoredRemovedIds tombstones below, the zombie
// survives, and the next persistAnnotations() sees it in currentIds →
// computeDiff produces removed=[] → the deletion is permanently lost.
const merged = mergeAnnotations(pdfBaselineAnnotations, diff);
for (const def of merged) {
if (!annotationMap.has(def.id)) {
annotationMap.set(def.id, { def, elements: [] });
}
}
for (const id of diff.removed) {
annotationMap.delete(id);
// Tombstone so the lazy per-page baseline scan (which runs AFTER this
// restore) doesn't resurrect it.
restoredRemovedIds.add(id);
}
// Restore form fields
for (const [k, v] of Object.entries(diff.formFields)) {
formFieldValues.set(k, v);
}
// If we have user changes (diff is not empty), mark dirty
if (
diff.added.length > 0 ||
diff.removed.length > 0 ||
Object.keys(diff.formFields).length > 0
) {
setDirty(true);
}
log.info(
`Restored ${annotationMap.size} annotations (${diff.added.length} added, ${diff.removed.length} removed), ${formFieldValues.size} form fields`,
);
} catch {
// Parse error or unavailable
} finally {
isRestoring = false;
}
}
// =============================================================================
// PDF.js Form Field Name → ID Mapping
// =============================================================================
/**
* Normalise a raw form field value into our string|boolean model.
* Returns null for empty/unfilled/button values so they don't clutter the
* panel or count as baseline.
*
* `type` is from getFieldObjects() (which knows field types); `raw` is
* preferably from page.getAnnotations().fieldValue (which is what the
* widget actually renders). A PDF can have the field-dict /V out of sync
* with the widget — AnnotationLayer trusts the widget, so we must too.
*/
function normaliseFieldValue(
type: string | undefined,
raw: unknown,
): string | boolean | null {
if (type === "button") return null;
// Checkbox/radio: fieldValue is the export string (e.g. "Yes"), "Off" = unset
if (type === "checkbox") {
return raw != null && raw !== "" && raw !== "Off" ? true : null;
}
if (type === "radiobutton") {
return raw != null && raw !== "" && raw !== "Off" ? String(raw) : null;
}
// Text/choice: fieldValue may be a string or an array of selections
if (Array.isArray(raw)) {
const joined = raw.filter(Boolean).join(", ");
return joined || null;
}
if (raw == null || raw === "") return null;
return String(raw);
}
/**
* Build mapping from field names (used by fill_form) to widget annotation IDs
* (used by annotationStorage).
*
* CRITICAL: getFieldObjects() returns field-dictionary IDs (the /T tree),
* but annotationStorage is keyed by WIDGET annotation IDs (what
* page.getAnnotations() returns). The two differ for PDFs where fields and
* their widget /Kids are separate objects. Using the wrong key makes all
* storage writes silently miss.
*/
async function buildFieldNameMap(
doc: pdfjsLib.PDFDocumentProxy,
): Promise {
let pushedToStorage = false;
fieldNameToIds.clear();
radioButtonValues.clear();
fieldNameToPage.clear();
fieldNameToLabel.clear();
fieldNameToOrder.clear();
cachedFieldObjects = null;
pdfBaselineFormValues.clear();
// getFieldObjects() gives us types, current values (/V), and defaults (/DV).
// We DON'T use its .id — that's the field dict ref, not the widget annot ref.
try {
cachedFieldObjects =
((await doc.getFieldObjects()) as Record | null) ?? null;
} catch {
// getFieldObjects may fail on some PDFs
}
// No AcroForm → nothing to map. Skip the per-page widget walk so form-free
// PDFs (the common large case) don't pull every page after first paint.
// getFieldObjects() itself only reads the catalog/AcroForm dict via range
// transport, so this gate is cheap.
if (!cachedFieldObjects || Object.keys(cachedFieldObjects).length === 0) {
return false;
}
// Scan every page's widget annotations to collect the CORRECT storage keys,
// plus labels, pages, positions, AND fieldValue (what the widget renders
// — which can differ from getFieldObjects().value if the PDF is internally
// inconsistent, e.g. after a pdf-lib setText silently failed).
const fieldPositions: Array<{ name: string; page: number; y: number }> = [];
const widgetFieldValues = new Map();
for (let pageNum = 1; pageNum <= doc.numPages; pageNum++) {
let annotations;
try {
const page = await doc.getPage(pageNum);
annotations = await page.getAnnotations();
} catch {
continue;
}
for (const ann of annotations) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const a = ann as any;
if (!a.fieldName || !a.id) continue;
// Widget annotation ID — this is what annotationStorage keys by
const ids = fieldNameToIds.get(a.fieldName) ?? [];
ids.push(a.id);
fieldNameToIds.set(a.fieldName, ids);
// Radio buttons: pdf.js creates WITHOUT setting
// .value, so reading target.value gives the HTML default "on".
// Remember each widget's export value so the input listener can
// report it instead.
if (a.radioButton && a.buttonValue != null) {
radioButtonValues.set(a.id, String(a.buttonValue));
}
if (!fieldNameToPage.has(a.fieldName)) {
fieldNameToPage.set(a.fieldName, pageNum);
}
if (a.alternativeText) {
fieldNameToLabel.set(a.fieldName, a.alternativeText);
}
if (a.rect) {
fieldPositions.push({ name: a.fieldName, page: pageNum, y: a.rect[3] });
}
// Capture the value the widget will actually render. First widget wins
// (radio groups share the field's /V so they all match anyway).
if (!widgetFieldValues.has(a.fieldName) && a.fieldValue !== undefined) {
widgetFieldValues.set(a.fieldName, a.fieldValue);
}
}
}
// Ordering: page ascending, then Y descending (top-to-bottom on page)
fieldPositions.sort((a, b) => a.page - b.page || b.y - a.y);
const seen = new Set();
let idx = 0;
for (const fp of fieldPositions) {
if (!seen.has(fp.name)) {
seen.add(fp.name);
fieldNameToOrder.set(fp.name, idx++);
}
}
// Import baseline values AND remap cachedFieldObjects to widget IDs.
//
// Baseline: prefer the widget's fieldValue (what AnnotationLayer renders)
// over getFieldObjects().value. A PDF can have the field-dict /V out of
// sync with the widget — if we import the field-dict value, the panel
// disagrees with what's on screen.
//
// Remap: pdf.js _bindResetFormAction (the PDF's in-document Reset button)
// iterates this structure, using .id to key storage and find DOM elements
// via [data-element-id=...]. Both use WIDGET ids. pdf-lib's save splits
// merged field+widget objects, so we rebuild with widget ids.
if (cachedFieldObjects) {
const remapped: Record = {};
for (const [name, fieldArr] of Object.entries(cachedFieldObjects)) {
const widgetIds = fieldNameToIds.get(name);
if (!widgetIds) continue; // no widget → not rendered anyway
// Type comes from getFieldObjects (widget annot data doesn't have it).
// Value: prefer the AcroForm field-tree value over the widget's
// fieldValue. pdf-lib's save() can leave a page widget pointing at a
// stale /V while the field tree has the new one (seen with comb text
// fields), and getAnnotations() reads the widget. If the two disagree
// we push the field-tree value into annotationStorage so the rendered
// input matches what's actually in /AcroForm.
const type = fieldArr.find((f) => f.type)?.type;
const fieldTreeRaw = fieldArr.find((f) => f.value != null)?.value;
const widgetRaw = widgetFieldValues.get(name);
const raw = fieldTreeRaw ?? widgetRaw;
const v = normaliseFieldValue(type, raw);
if (v !== null) {
pdfBaselineFormValues.set(name, v);
// Seed current state from baseline so the panel shows it. A
// restored localStorage diff (applied in restoreAnnotations) will
// overwrite specific fields the user changed.
if (!formFieldValues.has(name)) formFieldValues.set(name, v);
// Widget out of sync with field tree → force storage so
// AnnotationLayer renders the field-tree value, not the stale
// widget. (syncFormValuesToStorage skips baseline==current.)
if (fieldTreeRaw != null && fieldTreeRaw !== widgetRaw) {
setFieldInStorage(name, v);
pushedToStorage = true;
}
}
// Skip parent entries with no concrete id (radio groups: the /T tree
// has a parent with the export value, plus one child per widget).
const concrete = fieldArr.filter((f) => f.id && f.type);
remapped[name] = widgetIds.map((wid, i) => ({
...(concrete[i] ?? concrete[0] ?? fieldArr[0]),
id: wid,
}));
}
cachedFieldObjects = remapped;
}
log.info(`Built field name map: ${fieldNameToIds.size} fields`);
return pushedToStorage;
}
/**
* Set one form field's value in pdf.js's annotationStorage, in the format
* AnnotationLayer expects to READ when it re-renders.
*
* Radio buttons need per-widget booleans: pdf.js's RadioButtonWidgetAnnotation
* render() has inverted string coercion (`value !== buttonValue` → true for
* every NON-matching widget), so a string value on all widgets checks the
* first rendered one and clears the rest regardless of what you asked for.
* Match pdf.js's own change handler instead: `{value: true}` on the widget
* whose buttonValue matches, `{value: false}` on the siblings.
*
* Also patches the live DOM element for the current page so the user sees the
* change without waiting for a full re-render.
*/
function setFieldInStorage(name: string, value: string | boolean): void {
if (!pdfDocument) return;
const ids = fieldNameToIds.get(name);
if (!ids) return;
const storage = pdfDocument.annotationStorage;
// Radio group: at least one widget ID has a buttonValue recorded.
const isRadio = ids.some((id) => radioButtonValues.has(id));
if (isRadio) {
const want = String(value);
for (const id of ids) {
const checked = radioButtonValues.get(id) === want;
storage.setValue(id, { value: checked });
const el = formLayerEl.querySelector(
`input[data-element-id="${id}"]`,
) as HTMLInputElement | null;
if (el) el.checked = checked;
}
return;
}
// Text / checkbox / select: same value on every widget (a field can have
// multiple widget annotations sharing one /V).
const storageValue = typeof value === "boolean" ? value : String(value);
for (const id of ids) {
storage.setValue(id, { value: storageValue });
const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
| null;
if (!el) continue;
if (el instanceof HTMLInputElement && el.type === "checkbox") {
el.checked = !!value;
} else {
el.value = String(value);
}
}
}
/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values.
* Skips values that match the PDF's baseline — those are already in storage
* in pdf.js's native format (which may differ from our string/bool repr,
* e.g. checkbox stores "Yes" not `true`). Overwriting with our normalised
* form can break the Reset button's ability to restore defaults. */
function syncFormValuesToStorage(): void {
if (!pdfDocument || fieldNameToIds.size === 0) return;
for (const [name, value] of formFieldValues) {
if (pdfBaselineFormValues.get(name) === value) continue;
setFieldInStorage(name, value);
}
}
// =============================================================================
// PDF Save / Download with Annotations
// =============================================================================
/** Build annotated PDF bytes from the current state. */
async function getAnnotatedPdfBytes(): Promise {
if (!pdfDocument) throw new Error("No PDF loaded");
const fullBytes = await pdfDocument.getData();
// Only export user-added annotations; baseline ones are already in the PDF
const annotations: PdfAnnotationDef[] = [];
const baselineIds = new Set(pdfBaselineAnnotations.map((a) => a.id));
for (const tracked of annotationMap.values()) {
if (!baselineIds.has(tracked.def.id)) {
annotations.push(tracked.def);
}
}
// Baseline annotations the user deleted: strip their refs from /Annots so
// they don't reappear on reload. Include restored tombstones for pages we
// haven't scanned yet — those ids aren't in pdfBaselineAnnotations but the
// ref is still parseable from the id string. Ids without a recoverable ref
// (page-index fallback) can't be removed by-ref and are skipped.
const removedIds = new Set();
for (const a of pdfBaselineAnnotations) {
if (!annotationMap.has(a.id)) removedIds.add(a.id);
}
for (const id of restoredRemovedIds) {
if (!annotationMap.has(id)) removedIds.add(id);
}
const removedRefs = [...removedIds]
.map(parseAnnotationRef)
.filter((r): r is NonNullable => r !== null);
// Only write fields that actually changed vs. what's already in the PDF.
// Unchanged fields are no-ops at best, and at worst trip pdf-lib edge
// cases (max-length text, missing /Yes appearance, …) on fields the user
// never touched — which, before the per-field catch in
// buildAnnotatedPdfBytes, aborted every subsequent field.
//
// Fields the user cleared (present in baseline, absent from formFieldValues
// after clearAllItems()) still need an explicit "" / false so pdf-lib
// overwrites the original /V instead of leaving it intact.
const formFieldsOut = new Map();
for (const [name, value] of formFieldValues) {
if (pdfBaselineFormValues.get(name) !== value) {
formFieldsOut.set(name, value);
}
}
for (const [name, baselineValue] of pdfBaselineFormValues) {
if (!formFieldValues.has(name)) {
formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : "");
}
}
return buildAnnotatedPdfBytes(
fullBytes as Uint8Array,
annotations,
formFieldsOut,
removedRefs,
);
}
async function savePdf(): Promise {
if (!pdfDocument || !isDirty || saveInProgress) return;
const fileName =
pdfUrl
.replace(/^(file|computer):\/\//, "")
.split(/[/\\]/)
.pop() || pdfUrl;
const choice = await showConfirmDialog(
"Save PDF",
"Overwrite this file with your annotations and form edits?",
[{ label: "Cancel" }, { label: "Save", primary: true }],
fileName,
);
if (choice !== 1) return;
saveInProgress = true;
saveBtn.disabled = true;
saveBtn.title = "Saving...";
try {
const pdfBytes = await getAnnotatedPdfBytes();
const base64 = uint8ArrayToBase64(pdfBytes);
const result = await app.callServerTool({
name: "save_pdf",
arguments: { url: pdfUrl, data: base64 },
});
if (result.isError) {
log.error("Save failed:", result.content);
saveBtn.disabled = false; // let user retry
} else {
log.info("PDF saved");
// Record mtime so we recognize our own write in file_changed
const sc = result.structuredContent as { mtimeMs?: number } | undefined;
lastSavedMtime = sc?.mtimeMs ?? null;
const key = annotationStorageKey();
if (key) {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
}
// Reload from the bytes we just wrote. The previous approach (rebase
// baselines but keep the old pdfDocument) drifts: subsequent renders
// still rasterize stripped annotations from the old bytes, and the
// field/widget split that pdf-lib's save can create isn't reflected
// until reload anyway. Reload makes "what you see = what's on disk"
// an invariant. (file_changed echo is suppressed by lastSavedMtime.)
await reloadPdf();
}
} catch (err) {
log.error("Save failed:", err);
saveBtn.disabled = false;
} finally {
saveInProgress = false;
saveBtn.title = "Save to file (overwrites original)";
}
}
async function downloadAnnotatedPdf(): Promise {
if (!pdfDocument) return;
downloadBtn.disabled = true;
downloadBtn.title = "Preparing download...";
try {
const pdfBytes = await getAnnotatedPdfBytes();
const hasEdits = annotationMap.size > 0 || formFieldValues.size > 0;
const baseName = (pdfTitle || "document").replace(/\.pdf$/i, "");
const fileName = hasEdits ? `${baseName} - edited.pdf` : `${baseName}.pdf`;
const base64 = uint8ArrayToBase64(pdfBytes);
if (app.getHostCapabilities()?.downloadFile) {
const { isError } = await app.downloadFile({
contents: [
{
type: "resource",
resource: {
uri: `file:///${fileName}`,
mimeType: "application/pdf",
blob: base64,
},
},
],
});
if (isError) {
log.info("Download was cancelled or denied by host");
}
} else {
// Fallback: create blob URL and trigger download
const blob = new Blob([pdfBytes.buffer as ArrayBuffer], {
type: "application/pdf",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
} catch (err) {
log.error("Download error:", err);
} finally {
downloadBtn.disabled = false;
downloadBtn.title = "Download PDF";
}
}
// 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`;
// Drop any pinch preview transform in the same frame as the canvas
// resize so the size handoff is atomic.
pageWrapperEl.style.transform = "";
// Retina: pass dpr via page.render's `transform` (NOT ctx.scale).
// pdf.js sizes per-annotation canvases as
// width = rectW * outputScaleX * viewport.scale
// and outputScaleX is read from transform[0] (defaults to 1). A bare
// ctx.scale(dpr,dpr) leaves outputScaleX at 1, so the
// annotationCanvasMap canvases get a half-sized backing store on
// retina while their internal setTransform IS dpr-aware → the
// appearance renders 2× too big into a 1× buffer → cropped/shifted.
const dprTransform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
// Clear and setup text layer
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.
//
// annotationCanvasMap: pdf.js diverts annotations whose appearance needs
// its own bitmap (Stamp/Ink/FreeText/etc. with hasOwnCanvas) into
// per-id canvases instead of compositing onto the main canvas.
// renderImportedAnnotation() pulls from this map so those annotations
// become movable DOM elements with pixel-faithful visuals — instead of
// unselectable canvas pixels (the old "ghost annotation" problem) or
// our lossy text-label re-render.
annotationCanvasMap.clear();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderTask = (page.render as any)({
canvasContext: ctx,
viewport,
transform: dprTransform,
annotationCanvasMap,
// isEditing forces hasOwnCanvas=true for stamps regardless of /F
// NoRotate (StampAnnotation.mustBeViewedWhenEditing in pdf.worker).
// Without this, stamps without NoRotate composite onto the main canvas
// and deleting the "imported" overlay leaves an unclickable pixel
// behind. Other markup types still gate on noRotate; for those the
// overlay stays a transparent click-box (delete is UI-only until save).
isEditing: true,
});
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();
// 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 overlay layers to match canvas
highlightLayerEl.style.width = `${viewport.width}px`;
highlightLayerEl.style.height = `${viewport.height}px`;
annotationLayerEl.style.width = `${viewport.width}px`;
annotationLayerEl.style.height = `${viewport.height}px`;
// Render PDF.js AnnotationLayer for interactive form widgets
formLayerEl.innerHTML = "";
formLayerEl.style.width = `${viewport.width}px`;
formLayerEl.style.height = `${viewport.height}px`;
// Set CSS custom properties so AnnotationLayer font-size rules work correctly
formLayerEl.style.setProperty("--scale-factor", `${scale}`);
formLayerEl.style.setProperty("--total-scale-factor", `${scale}`);
try {
const annotations = await page.getAnnotations();
// Lazy baseline import — piggyback on the annotations we just fetched
// for this page instead of walking all pages upfront.
scanPageBaselineAnnotations(pageToRender, annotations);
if (annotations.length > 0) {
const linkService = {
getDestinationHash: () => "#",
getAnchorUrl: () => "#",
addLinkAttributes: () => {},
isPageVisible: () => true,
isPageCached: () => true,
externalLinkEnabled: true,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const annotationLayer = new AnnotationLayer({
div: formLayerEl,
page,
viewport,
annotationStorage: pdfDocument.annotationStorage,
linkService,
accessibilityManager: null,
annotationCanvasMap: null,
annotationEditorUIManager: null,
structTreeLayer: null,
commentManager: null,
} as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// Only feed Widgets (form fields) here. Markup annotations are
// owned by #annotation-layer; letting AnnotationLayer create
// elements for them in #form-layer adds invisible
// pointer-events:auto boxes that steal clicks from our overlays.
const widgetAnns = annotations.filter(
(a: { subtype?: string }) => a.subtype === "Widget",
);
await annotationLayer.render({
annotations: widgetAnns,
div: formLayerEl,
page,
viewport,
renderForms: true,
linkService,
annotationStorage: pdfDocument.annotationStorage,
fieldObjects: cachedFieldObjects,
} as any);
// Fix combo reset: pdf.js's resetform handler sets all
// option.selected = (option.value === defaultFieldValue), and
// defaultFieldValue is typically null — nothing matches. On a
// non-multiple