---
format: typebulb/v1
name: PDF Extractor
---

**code.tsx**

```tsx
import React, { useState, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import * as pdfjs from "pdfjs-dist";
import { PDFDocument } from "pdf-lib";

// Setup PDF.js Worker using the Typebulb Proxy Pattern.
// Use pdfjs.version so the worker always matches the loaded API version.
pdfjs.GlobalWorkerOptions.workerSrc = tb.proxy(`https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`);

const App = () => {
  const [file, setFile] = useState<File | null>(null);
  const [pages, setPages] = useState<{ id: number; url: string }[]>([]);
  const [selectedPages, setSelectedPages] = useState<Set<number>>(new Set());
  const [loading, setLoading] = useState(false);
  const [processing, setProcessing] = useState(false);
  const [status, setStatus] = useState("Drop a PDF or click to upload");
  const [isDragging, setIsDragging] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    processFile(e.target.files?.[0]);
  };

  const processFile = async (selectedFile?: File) => {
    if (!selectedFile || selectedFile.type !== "application/pdf") return;

    setFile(selectedFile);
    setLoading(true);
    setPages([]);
    setSelectedPages(new Set());
    setStatus("Loading PDF...");

    try {
      const arrayBuffer = await selectedFile.arrayBuffer();
      const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;

      for (let i = 1; i <= pdf.numPages; i++) {
        const page = await pdf.getPage(i);
        const viewport = page.getViewport({ scale: 0.3 }); // Small scale for thumbnails
        const canvas = document.createElement("canvas");
        const context = canvas.getContext("2d");
        canvas.height = viewport.height;
        canvas.width = viewport.width;

        await page.render({ canvasContext: context!, viewport, canvas }).promise;
        const url = canvas.toDataURL();
        setPages(prev => [...prev, { id: i, url }]);
        setStatus(`Rendering page ${i}/${pdf.numPages}...`);
      }
      setStatus("Select pages to extract");
    } catch (err) {
      console.error(err);
      setStatus("Error loading PDF");
    } finally {
      setLoading(false);
    }
  };

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(true);
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    setIsDragging(false);
    processFile(e.dataTransfer.files?.[0]);
  };

  const togglePage = (index: number) => {
    const next = new Set(selectedPages);
    if (next.has(index)) next.delete(index);
    else next.add(index);
    setSelectedPages(next);
  };

  const selectAll = () => {
    setSelectedPages(new Set(pages.map(p => p.id)));
  };

  const handleExport = async () => {
    if (!file || selectedPages.size === 0) return;

    setProcessing(true);
    setStatus("Generating new PDF...");

    try {
      const existingPdfBytes = await file.arrayBuffer();
      const pdfDoc = await PDFDocument.load(existingPdfBytes);
      const newPdfDoc = await PDFDocument.create();

      // Page indices are 0-based in pdf-lib
      const indices = Array.from(selectedPages).sort((a, b) => a - b).map(i => i - 1);
      const copiedPages = await newPdfDoc.copyPages(pdfDoc, indices);

      copiedPages.forEach((page) => newPdfDoc.addPage(page));

      const pdfBytes = await newPdfDoc.save();
      const blob = new Blob([pdfBytes], { type: "application/pdf" });
      const url = URL.createObjectURL(blob);

      const link = document.createElement("a");
      link.href = url;
      link.download = `extracted_pages_${file.name}`;
      link.click();

      setStatus("Export successful!");
    } catch (err) {
      console.error(err);
      setStatus("Export failed");
    } finally {
      setProcessing(false);
    }
  };

  const ua = navigator.userAgent;
  const isWebView = (/iPhone|iPad|iPod/i.test(ua) && !ua.includes('Safari')) || /; wv\)/.test(ua);

  if (isWebView) return (
    <div className="container">
      <div className="in-app-banner">
        Your in-app browser can't download files. Please open this page directly in your browser.
        <button className="copy-link-btn" onClick={async () => tb.copy(await tb.url())}>
          Copy Link
        </button>
      </div>
    </div>
  );

  return (
    <div className="container">
      <header className="main-header">
        <h1>PDF Page Extractor</h1>
        <p className="subtitle">Select and extract specific pages into a new PDF. Runs entirely in your browser.</p>
      </header>

      <div 
        className={`upload-section ${isDragging ? 'dragging' : ''}`}
        onDragOver={handleDragOver}
        onDragLeave={() => setIsDragging(false)}
        onDrop={handleDrop}
      >
        <label className={`upload-btn ${loading ? 'loading' : ''} ${file ? 'has-file' : ''}`}>
          {file ? `File: ${file.name}` : "Choose PDF File"}
          <input type="file" accept=".pdf" onChange={handleFileChange} disabled={loading} />
        </label>
        <span className="status-badge">{status}</span>
      </div>

      {pages.length > 0 && (
        <div className="workspace animate-in">
          <div className="toolbar">
            <button 
              className="primary-btn" 
              onClick={handleExport} 
              disabled={selectedPages.size === 0 || processing}
            >
              {processing ? "Processing..." : `Export ${selectedPages.size} Selected Pages`}
            </button>
            <button className="secondary-btn" onClick={selectAll}>
              Select All
            </button>
            <button className="secondary-btn" onClick={() => setSelectedPages(new Set())}>
              Clear Selection
            </button>
          </div>

          <div className="thumbnail-grid">
            {pages.map((page) => (
              <div 
                key={page.id} 
                className={`page-card ${selectedPages.has(page.id) ? 'selected' : ''}`}
                onClick={() => togglePage(page.id)}
              >
                <div className="page-number">{page.id}</div>
                <img src={page.url} alt={`Page ${page.id}`} />
                <div className="selection-indicator">
                  <div className="check">✓</div>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      {!file && !loading && (
        <div className="placeholder-card">
          <div className="icon">📄</div>
          <h3>No PDF Loaded</h3>
          <p>Upload a document to view and extract specific pages locally in your browser.</p>
        </div>
      )}
    </div>
  );
};

const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**

```css
:root {
  --bg: #fafafa;
  --text: #1d1d1f;
  --text-muted: #6e6e73;
  --primary-action: #f5f5f5;
  --border: rgba(0, 0, 0, 0.1);
  --card-bg: #ffffff;
  --selection: rgba(0, 0, 0, 0.05);
  --button-bg: #1d1d1f;
  --button-text: #ffffff;
  --radius: 16px;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
  --accent: #1d1d1f;
}

html[data-theme="dark"] {
  --bg: #1c1c1c;
  --text: #f5f5f7;
  --text-muted: #a1a1a6;
  --primary-action: #3a3a3c;
  --border: rgba(255, 255, 255, 0.1);
  --card-bg: #232323;
  --selection: rgba(255, 255, 255, 0.08);
  --button-bg: #f5f5f7;
  --button-text: #1d1d1f;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
  --accent: #f5f5f7;
}

body {
  background-color: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", "SF Pro Display", sans-serif;
  margin: 0;
  padding: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  letter-spacing: -0.01em;
  line-height: 1.5;
}

.container {
  max-width: 1000px;
  margin: 0 auto;
  padding: 3rem 2rem;
  min-height: 100vh;
}

.main-header {
  margin-bottom: 3rem;
  text-align: center;
}

h1 {
  font-size: 2.5rem;
  font-weight: 700;
  margin: 0 0 0.5rem 0;
  letter-spacing: -0.03em;
  line-height: 1.1;
}

.subtitle {
  color: var(--text-muted);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.6;
  max-width: 480px;
  margin: 0 auto;
}

.upload-section {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
  margin-bottom: 2.5rem;
}

.upload-btn {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 1rem 2rem;
  border-radius: 14px;
  cursor: pointer;
  font-weight: 600;
  font-size: 0.9375rem;
  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: var(--shadow-sm);
  letter-spacing: -0.01em;
}

.upload-btn:hover {
  box-shadow: var(--shadow-md);
}

.upload-btn.has-file {
  background: var(--card-bg);
  color: var(--text);
}

.upload-btn input {
  display: none;
}

.upload-btn.disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none !important;
}

.status-badge {
  font-size: 0.8125rem;
  color: var(--text-muted);
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

.toolbar {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  margin-bottom: 2rem;
}

.primary-btn {
  background: var(--selection);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 0.875rem 1.75rem;
  border-radius: 12px;
  font-weight: 600;
  font-size: 0.9375rem;
  cursor: pointer;
  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: var(--shadow-sm);
  letter-spacing: -0.01em;
}

.secondary-btn {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 0.875rem 1.75rem;
  border-radius: 12px;
  font-weight: 600;
  font-size: 0.9375rem;
  cursor: pointer;
  transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: var(--shadow-sm);
  letter-spacing: -0.01em;
}

.primary-btn:hover {
  box-shadow: var(--shadow-md);
  background: var(--border);
}

.secondary-btn:hover {
  box-shadow: var(--shadow-md);
}

.primary-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  transform: none !important;
  box-shadow: var(--shadow-sm) !important;
}

.thumbnail-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 1.5rem;
}

.page-card {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1rem;
  position: relative;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  user-select: none;
  box-shadow: var(--shadow-md);
}

.page-card img {
  width: 100%;
  height: auto;
  border-radius: 8px;
  display: block;
}

.page-card:hover {
  box-shadow: var(--shadow-lg);
}

.page-card.selected {
  border-color: var(--accent);
  transform: scale(0.97);
  box-shadow: var(--shadow-lg);
}

.page-number {
  position: absolute;
  top: -10px;
  left: -10px;
  background: var(--text);
  color: var(--bg);
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  font-weight: 600;
  box-shadow: var(--shadow-md);
}

.selection-indicator {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 24px;
  height: 24px;
  border: 2px solid var(--border);
  border-radius: 50%;
  background: var(--card-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

.page-card.selected .selection-indicator {
  background: var(--accent);
  border-color: var(--accent);
}

.selection-indicator .check {
  color: var(--bg);
  font-size: 13px;
  font-weight: 700;
  display: none;
}

.page-card.selected .check {
  display: block;
}

.placeholder-card {
  text-align: center;
  padding: 5rem 2rem;
  background: var(--card-bg);
  border: 1px dashed var(--border);
  border-radius: 20px;
  color: var(--text-muted);
}

.placeholder-card .icon {
  font-size: 3rem;
  margin-bottom: 1rem;
  opacity: 0.5;
}

.placeholder-card h3 {
  color: var(--text);
  margin: 0 0 0.5rem 0;
  font-weight: 600;
}

.placeholder-card p {
  margin: 0;
  font-size: 0.9375rem;
}

.in-app-banner {
  background: var(--selection);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 1rem 1.25rem;
  margin-bottom: 1.5rem;
  font-size: 0.875rem;
  color: var(--text);
  line-height: 1.5;
  text-align: center;
}

.copy-link-btn {
  display: block;
  margin: 0.75rem auto 0;
  padding: 0.5rem 1rem;
  font-size: 0.8rem;
  font-weight: 600;
  cursor: pointer;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: 8px;
  transition: all 0.2s;
}

.copy-link-btn:hover { box-shadow: var(--shadow-sm); }

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

.animate-in {
  animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

@media (max-width: 640px) {
  .container {
    padding: 2rem 1.25rem;
  }

  h1 {
    font-size: 2rem;
  }

  .subtitle {
    font-size: 0.9375rem;
  }

  .toolbar {
    flex-wrap: wrap;
  }

  .thumbnail-grid {
    grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
    gap: 1rem;
  }
}

```
**index.html**

```html
<div id="root"></div>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "pdfjs-dist": "^5.4.530",
    "pdf-lib": "^1.17.1"
  },
  "description": "PDF Page Extractor - Select and extract specific pages into a new PDF."
}
```