Skip to content

Preview — PDF rendering (pdfjs-dist, zoom, scroll-position preservation) #727

Description

@jeonghun-jj-lee

Preview — PDF rendering (pdfjs-dist, zoom, scroll-position preservation)

Important

Problem — Amicode cannot display PDF files. Researchers writing papers must open a separate PDF viewer to read compiled documents, figures, or reference papers.

Approach — Add pdfjs-dist as an eager-bundled dependency. Build a PDFViewer component that takes a file path, fetches the content as binary (base64 via the file API), renders pages to canvas elements in a scrollable container, and supports zoom controls. The viewer preserves scroll position across document reloads (critical for TeX compilation hot-reload in slice 5). Wired into the Preview panel's renderer dispatch (slice 2) for .pdf files.

Scope — in: pdfjs-dist bundling + Vite worker config, PDFViewer component, canvas-based page rendering, zoom controls, scroll preservation on reload. out: PDF annotation, text selection, search-within-PDF, split-pane source+PDF view.

Acceptance Criteria

  • pdfjs-dist is added as a dependency with its worker file configured as a separate Vite asset (not inlined)
  • PDFViewer component accepts a file path, fetches binary content via the SDK file API, and renders all pages to <canvas> elements in a scrollable container
  • Zoom controls (in/out/reset, range ~50%–200%) are integrated — extract the inline zoom JSX from session-preview-tab.tsx:246-279 (currently a <input> + two <button> elements with local signals, not a reusable component) into a shared ZoomControl component, then reuse it here
  • Scroll position is preserved when the document reloads (same file path, new content — the TeX recompilation case)
  • Loading state shown while PDF is being fetched/rendered
  • Error state shown when the PDF fails to load or is corrupted ("Could not load PDF")
  • The viewer is always read-only — no editing, no annotation
  • Pages render at sufficient resolution for the current zoom level (device pixel ratio aware)
  • The component is self-contained — mountable by the renderer dispatch without knowledge of PDF internals

Testing Decisions

  • Unit test the PDF viewer's loading/error state transitions (mock pdfjs-dist document loading)
  • Unit test zoom control range clamping and scroll-position restoration logic
  • Integration test: render a small fixture PDF (1–2 pages) and verify canvas elements are created
  • Vite config test: verify the worker file is emitted as a separate asset (build output check)

Key Decisions

Eager bundle. pdfjs-dist is imported statically, not lazy-loaded. Assets are local (embedded in the binary), so eager import eliminates an async boundary. The worker file must be configured as a separate Vite asset — the current vite.config.ts (packages/app/vite.config.ts) has no worker configuration; add build.rollupOptions or use Vite's ?worker&url import suffix to emit pdf.worker.mjs as a separate chunk.

Bundle size. pdfjs-dist adds ~400-500KB (main library) + ~350KB (worker). Combined with the CodeMirror addition in slice 4 (~150KB), the total webview payload grows by ~1MB gzipped. This is acceptable for the functionality gained but should be verified against the Amicode binary size budget after integration.

Canvas rendering, not SVG. Canvas is faster for multi-page documents and handles complex PDF content (embedded fonts, vector graphics) more reliably than SVG rendering.

Scroll preservation via page index + offset. On reload, store the current top-most visible page index and the scroll offset within that page. After the new document renders, restore to the same position. This handles the TeX hot-reload case where page count may change slightly.

No text layer. Text selection within the PDF is out of scope. The canvas renders the visual content only. This simplifies the implementation and avoids the text-layer positioning issues that plague many PDF viewers.

Constraints & Invariants

  • The PDF viewer is always read-only
  • The viewer handles malformed/corrupted PDFs gracefully (error state, not crash)
  • The pdfjs-dist worker is never inlined — always a separate asset
  • Zoom state is local to the PDF viewer instance (not shared with other renderers)

Prior Art

  • pdfjs-dist — Mozilla's PDF rendering library (the standard in web PDF rendering)
  • Zoom controls in session-preview-tab.tsx:246-279 — inline JSX (<input> + two <button> elements with local zoom/zoomIn/zoomOut signals). Must be extracted into a shared ZoomControl component as a prerequisite of this slice.
  • File API: GET /file/content?path=... returns { type: "binary", content: "<base64>", encoding: "base64" } for PDFs (confirmed in SDK FileContent type at types.gen.ts:2296-2314)

Source

harmoniqs/opencode — new PDFViewer component in packages/app/src/components/, Vite config in packages/app/vite.config.ts

Implementation Plan

Files to create

File Repo Purpose
packages/app/src/components/pdf-viewer.tsx opencode PDFViewer component: pdfjs-dist canvas rendering + zoom + scroll preservation
packages/app/src/components/zoom-control.tsx opencode Extracted shared zoom widget (from session-preview-tab.tsx:246-279)

Files to modify

File Repo Change
packages/app/package.json opencode Add pdfjs-dist dependency
packages/app/vite.js opencode Add optimizeDeps.include: ['pdfjs-dist'] to the desktop plugin config (worker config already exists at line 31-33: worker: { format: "es" })
packages/app/src/components/session/session-preview-tab.tsx opencode Replace inline zoom JSX (lines 246-279) with shared <ZoomControl> import
packages/app/src/components/session/preview-content-area.tsx opencode (from slice 2) Replace PDF placeholder with <PDFViewer>

Integration points (exact)

1. pdfjs-dist worker setup — use Vite's ?url import suffix (the desktop plugin at vite.js:31 already sets worker: { format: "es" }):

// pdf-viewer.tsx
import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";

pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;

2. Binary content decoding — the SDK returns base64 in FileContent.content when type === "binary". Decode to Uint8Array for pdfjs-dist:

function base64ToUint8Array(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}

3. PDFViewer component — mount via ref + onMount + onCleanup (matching the pierre/diffs pattern at session-ui/src/components/file-ssr.tsx:90-145):

export function PDFViewer(props: {
  data: Uint8Array;
  zoom?: number;
  onZoomChange?: (zoom: number) => void;
}) {
  let containerRef!: HTMLDivElement;
  let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
  const [pageCount, setPageCount] = createSignal(0);

  // Track scroll position for preservation
  let lastScrollPage = 0;
  let lastScrollOffset = 0;

  onMount(async () => {
    const doc = await pdfjsLib.getDocument({ data: props.data }).promise;
    pdfDoc = doc;
    setPageCount(doc.numPages);
    renderAllPages(doc, props.zoom ?? 1.0);
  });

  // Re-render on data change (TeX recompilation) — preserve scroll
  createEffect(on(() => props.data, async (data) => {
    saveScrollPosition();
    const doc = await pdfjsLib.getDocument({ data }).promise;
    pdfDoc = doc;
    setPageCount(doc.numPages);
    renderAllPages(doc, props.zoom ?? 1.0);
    restoreScrollPosition();
  }));

  onCleanup(() => { pdfDoc?.destroy(); });

  return <div ref={containerRef} class="overflow-auto h-full" />;
}

4. Scroll preservation — track page index + offset:

function saveScrollPosition() {
  const canvases = containerRef.querySelectorAll("canvas");
  for (let i = 0; i < canvases.length; i++) {
    const rect = canvases[i].getBoundingClientRect();
    if (rect.top >= 0 || rect.bottom > 0) {
      lastScrollPage = i;
      lastScrollOffset = -rect.top;
      return;
    }
  }
}

function restoreScrollPosition() {
  requestAnimationFrame(() => {
    const canvases = containerRef.querySelectorAll("canvas");
    if (canvases[lastScrollPage]) {
      canvases[lastScrollPage].scrollIntoView();
      containerRef.scrollTop += lastScrollOffset;
    }
  });
}

5. Extract ZoomControl — currently inline JSX at session-preview-tab.tsx:246-279:

// zoom-control.tsx
export function ZoomControl(props: {
  zoom: () => number;
  onZoomIn: () => void;
  onZoomOut: () => void;
  onZoomReset: () => void;
}) {
  return (
    <div class="flex items-center gap-1">
      <button onClick={props.onZoomOut}></button>
      <span class="text-11-medium text-text-muted w-8 text-center">
        {Math.round(props.zoom() * 100)}%
      </span>
      <button onClick={props.onZoomIn}>+</button>
    </div>
  );
}

6. Vite config — add to the desktop plugin's config() return at vite.js:26:

optimizeDeps: {
  include: ['pdfjs-dist'],
},

Bundle size

pdfjs-dist adds ~400-500KB (main) + ~350KB (worker) gzipped. The worker is loaded as a separate file (not inlined) thanks to the ?url import pattern. Verify final bundle size after integration with npx vite build --report.

Verification

# Unit tests (opencode app uses bun test, not vitest)
cd ~/harmoniqs/opencode/packages/app && bun test --conditions=solid --preload ./happydom.ts src/components/pdf-viewer.test.ts
cd ~/harmoniqs/opencode/packages/app && bun test --conditions=solid --preload ./happydom.ts src/components/zoom-control.test.ts

# Test expectations:
# - PDFViewer renders N canvas elements for N-page PDF (mock pdfjs-dist)
# - ZoomControl displays percentage, fires callbacks
# - Scroll preservation: save/restore round-trips correctly
# - Error state: corrupted PDF shows error message, not crash

# Vite build check:
cd ~/harmoniqs/opencode && npx vite build 2>&1 | grep -i worker  # verify pdf.worker.mjs emitted

# Visual check in Extension Dev Host (F5):
# 1. Open a project with a .pdf file
# 2. Click the PDF in the Preview tree → pages render in canvas
# 3. Zoom in/out → pages re-render at higher/lower resolution
# 4. Scroll to page 5, trigger a re-render → scroll stays at page 5

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

afkImplementable without human interactionarea:uienhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions