diff --git a/package.json b/package.json
index 4f674fd49..c74d62c75 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,7 @@
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"node-pty": "^1.1.0",
+ "pdfjs-dist": "^5.6.205",
"radix-ui": "^1.4.3",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 10e501835..3be0c43af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -148,6 +148,9 @@ importers:
node-pty:
specifier: ^1.1.0
version: 1.1.0(patch_hash=02e16954edbb8e511963557e3b2c80f8a6fb230cd0c6a75ade936022f3347322)
+ pdfjs-dist:
+ specifier: ^5.6.205
+ version: 5.6.205
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -983,6 +986,81 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
+ '@napi-rs/canvas-android-arm64@0.1.99':
+ resolution: {integrity: sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@napi-rs/canvas-darwin-arm64@0.1.99':
+ resolution: {integrity: sha512-lupMDMy1+H38dhyCcLirOKKVUyzzlxi7j7rGPLI3vViMHOoPjcXO1b10ivy+ad+q6MiwHfoLjKTCoLke5ySOBg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@napi-rs/canvas-darwin-x64@0.1.99':
+ resolution: {integrity: sha512-fdz02t4w8n6Ii/rYhWig6STb/zcTmCC/6YZTGmjoDeidDwn9Wf0ukQVynhCPEs29vqUc66wHZKsuIgMs9tycCg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.99':
+ resolution: {integrity: sha512-w4FwVwlNo00ezeRhfY62IVIyt6G3u8wodkPtiqWc52BUHx+VDBUM2vkS3ogfANaLI7hnf3s6WK4LyZVUjBg1lA==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.99':
+ resolution: {integrity: sha512-8JvHeexKQ8c7g0q7YJ29NVQwnf1ePghP9ys9ZN0R0qzyqJQ9Uw6N9qnDINArlm3IYHexB7LjzArIfhQiqSDGvQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.99':
+ resolution: {integrity: sha512-Z+6nyLdJXWzLPVxi4H6g9TJop4DwN3KSgHWto5JCbZV5/uKoVqcSynPs0tGlUHOoWI8S8tEvJspz51GQkvr07w==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.99':
+ resolution: {integrity: sha512-jAnfOUv4IO1l8Levk5t85oVtEBOXLa07KnIUgWo1CDlPxiqpxS3uBfiE38Lvj/CQgHaNF6Nxk/SaemwLgsVJgw==}
+ engines: {node: '>= 10'}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.99':
+ resolution: {integrity: sha512-mIkXw3fGmbYyFjSmfWEvty4jN+rwEOmv0+Dy9bRvvTzLYWCgm3RMgUEQVfAKFw96nIRFnyNZiK83KNQaVVFjng==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.99':
+ resolution: {integrity: sha512-f3Uz2P0RgrtBHISxZqr6yiYXJlTDyCVBumDacxo+4AmSg7z0HiqYZKGWC/gszq3fbPhyQUya1W2AEteKxT9Y6A==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.99':
+ resolution: {integrity: sha512-XE6KUkfqRsCNejcoRMiMr3RaUeObxNf6y7dut3hrq2rn7PzfRTZgrjF1F/B2C7FcdgqY/vSHWpQeMuNz1vTNHg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.99':
+ resolution: {integrity: sha512-plMYGVbc/vmmPF9MtmHbwNk1rL1Aj53vQZt+Gnv1oZn6gmd9jEHHJ0n9Nd2nxa5sKH7TS5IjkCDM6289O0d6PQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/canvas@0.1.99':
+ resolution: {integrity: sha512-zN4eQlK3eBf7aJBcTHZilpBH3tDekBzPMIWC8r0s94Ecl73XfOyFi4w7yKFMRVUT0lvNQjtOL8YSrwqQj6mZFg==}
+ engines: {node: '>= 10'}
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
@@ -4847,6 +4925,9 @@ packages:
node-pty@1.1.0:
resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==}
+ node-readable-to-web-readable-stream@0.4.2:
+ resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
+
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
@@ -5006,6 +5087,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pdfjs-dist@5.6.205:
+ resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
+ engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
+
pe-library@0.4.1:
resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
engines: {node: '>=12', npm: '>=6'}
@@ -6800,6 +6885,54 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@napi-rs/canvas-android-arm64@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-darwin-arm64@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-darwin-x64@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm-gnueabihf@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-gnu@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-arm64-musl@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-riscv64-gnu@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-gnu@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-linux-x64-musl@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-win32-arm64-msvc@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas-win32-x64-msvc@0.1.99':
+ optional: true
+
+ '@napi-rs/canvas@0.1.99':
+ optionalDependencies:
+ '@napi-rs/canvas-android-arm64': 0.1.99
+ '@napi-rs/canvas-darwin-arm64': 0.1.99
+ '@napi-rs/canvas-darwin-x64': 0.1.99
+ '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.99
+ '@napi-rs/canvas-linux-arm64-gnu': 0.1.99
+ '@napi-rs/canvas-linux-arm64-musl': 0.1.99
+ '@napi-rs/canvas-linux-riscv64-gnu': 0.1.99
+ '@napi-rs/canvas-linux-x64-gnu': 0.1.99
+ '@napi-rs/canvas-linux-x64-musl': 0.1.99
+ '@napi-rs/canvas-win32-arm64-msvc': 0.1.99
+ '@napi-rs/canvas-win32-x64-msvc': 0.1.99
+ optional: true
+
'@noble/ciphers@1.3.0': {}
'@noble/curves@1.9.7':
@@ -10925,6 +11058,9 @@ snapshots:
dependencies:
node-addon-api: 7.1.1
+ node-readable-to-web-readable-stream@0.4.2:
+ optional: true
+
node-releases@2.0.36: {}
nopt@8.1.0:
@@ -11115,6 +11251,11 @@ snapshots:
pathe@2.0.3: {}
+ pdfjs-dist@5.6.205:
+ optionalDependencies:
+ '@napi-rs/canvas': 0.1.99
+ node-readable-to-web-readable-stream: 0.4.2
+
pe-library@0.4.1: {}
pend@1.2.0: {}
diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx
index 42895a629..15e1abf89 100644
--- a/src/renderer/src/components/editor/ImageViewer.tsx
+++ b/src/renderer/src/components/editor/ImageViewer.tsx
@@ -1,6 +1,7 @@
import { Image as ImageIcon, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react'
import { type JSX, useEffect, useMemo, useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
+import PdfViewer from './PdfViewer'
const FALLBACK_IMAGE_MIME_TYPE = 'image/png'
const MIN_ZOOM = 0.25
@@ -42,18 +43,11 @@ export default function ImageViewer({
const zoomPercent = Math.round(zoom * 100)
useEffect(() => {
- // Reset error state so the component re-attempts rendering when inputs change
- // (e.g. switching to a different file after a previous corrupt payload).
setImageError(false)
-
- if (!cleanedContent) {
+ if (!cleanedContent || isPdf) {
setPreviewUrl(null)
return
}
-
- // Why: window.atob() throws a DOMException if cleanedContent contains
- // invalid base64 characters (e.g. corrupt or truncated data). We catch
- // that so the component degrades to the error state instead of crashing.
let binary: string
try {
binary = window.atob(cleanedContent)
@@ -61,21 +55,18 @@ export default function ImageViewer({
setImageError(true)
return
}
-
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i)
}
-
- // Why: large binary previews behave better as object URLs than giant
- // inline data URLs. PDFs especially can surface awkward native viewer UI
- // when loaded from a data URL, and object URLs avoid keeping megabytes of
- // base64 text in the DOM.
const objectUrl = URL.createObjectURL(new Blob([bytes], { type: mimeType }))
setPreviewUrl(objectUrl)
-
return () => URL.revokeObjectURL(objectUrl)
- }, [cleanedContent, mimeType])
+ }, [cleanedContent, mimeType, isPdf])
+
+ if (isPdf) {
+ return
+ }
if (imageError) {
return (
@@ -95,133 +86,108 @@ export default function ImageViewer({
)
}
- const previewPane = isPdf ? (
-
- {/* Why: Electron's Chromium PDF viewer can fail to initialize inside a
- sandboxed iframe even when the Blob URL is valid. Using keeps
- the preview isolated to the browser's native PDF surface without
- depending on iframe document execution. */}
-
-
- ) : (
- setIsPopupOpen(true)}
- title="Open image in popup"
- >
-
-
{
- const img = event.currentTarget
- setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight })
- }}
- onError={() => setImageError(true)}
- />
-
-
- )
-
return (
<>
- {previewPane}
+
setIsPopupOpen(true)}
+ title="Open image in popup"
+ >
+
+
{
+ const img = event.currentTarget
+ setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight })
+ }}
+ onError={() => setImageError(true)}
+ />
+
+
- {!isPdf && (
-
- setZoom((prev) => Math.max(MIN_ZOOM, prev / ZOOM_STEP))}
- disabled={zoom <= MIN_ZOOM}
- title="Zoom out"
- >
-
-
- setZoom(1)}
- disabled={zoom === 1}
- title="Reset zoom"
- >
-
-
- setZoom((prev) => Math.min(MAX_ZOOM, prev * ZOOM_STEP))}
- disabled={zoom >= MAX_ZOOM}
- title="Zoom in"
- >
-
-
- {zoomPercent}%
-
- )}
+
+ setZoom((prev) => Math.max(MIN_ZOOM, prev / ZOOM_STEP))}
+ disabled={zoom <= MIN_ZOOM}
+ title="Zoom out"
+ >
+
+
+ setZoom(1)}
+ disabled={zoom === 1}
+ title="Reset zoom"
+ >
+
+
+ setZoom((prev) => Math.min(MAX_ZOOM, prev * ZOOM_STEP))}
+ disabled={zoom >= MAX_ZOOM}
+ title="Zoom in"
+ >
+
+
+ {zoomPercent}%
+
{filename}
- {!isPdf && imageDimensions && (
+ {imageDimensions && (
{imageDimensions.width} x {imageDimensions.height}
)}
- {isPdf &&
PDF preview }
{estimatedSize}
- {/* Why: native Chromium PDF embeds need direct pointer input for paging,
- zoom, selection, and sidebar toggles. Intercepting every click to
- force a second modal preview breaks those controls on some Macs and
- can leave the dialog shell visible around a half-initialized viewer. */}
- {!isPdf && (
-
-
- {filename}
- Full-size image preview
-
-
{filename}
-
setIsPopupOpen(false)}
- >
-
- Close
-
+
+
+ {filename}
+ Full-size image preview
+
+
{filename}
+
setIsPopupOpen(false)}
+ >
+
+ Close
+
+
+
+
+
-
-
-
-
-
-
-
Press Esc to close
-
{zoomPercent}%
-
-
-
- )}
+
+
+
Press Esc to close
+
{zoomPercent}%
+
+
+
>
)
}
diff --git a/src/renderer/src/components/editor/PdfFind.tsx b/src/renderer/src/components/editor/PdfFind.tsx
new file mode 100644
index 000000000..4887e5d7c
--- /dev/null
+++ b/src/renderer/src/components/editor/PdfFind.tsx
@@ -0,0 +1,170 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ChevronUp, ChevronDown, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import type { EventBus } from 'pdfjs-dist/web/pdf_viewer.mjs'
+
+type PdfFindProps = {
+ isOpen: boolean
+ onClose: () => void
+ eventBusRef: React.RefObject
| null>
+}
+
+export default function PdfFind({
+ isOpen,
+ onClose,
+ eventBusRef
+}: PdfFindProps): React.JSX.Element | null {
+ const inputRef = useRef(null)
+ const [query, setQuery] = useState('')
+ const [activeMatch, setActiveMatch] = useState(0)
+ const [totalMatches, setTotalMatches] = useState(0)
+
+ const dispatchFind = useCallback(
+ (type: string, findPrevious = false): void => {
+ const eventBus = eventBusRef.current
+ if (!eventBus) {
+ return
+ }
+ eventBus.dispatch('find', {
+ source: null,
+ type,
+ query,
+ highlightAll: true,
+ caseSensitive: false,
+ entireWord: false,
+ findPrevious
+ })
+ },
+ [eventBusRef, query]
+ )
+
+ const findNext = useCallback(() => {
+ if (query) {
+ dispatchFind('again', false)
+ }
+ }, [query, dispatchFind])
+
+ const findPrevious = useCallback(() => {
+ if (query) {
+ dispatchFind('again', true)
+ }
+ }, [query, dispatchFind])
+
+ useEffect(() => {
+ if (isOpen) {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ } else {
+ const eventBus = eventBusRef.current
+ if (eventBus) {
+ eventBus.dispatch('findbarclose', { source: null })
+ }
+ setActiveMatch(0)
+ setTotalMatches(0)
+ }
+ }, [isOpen, eventBusRef])
+
+ useEffect(() => {
+ if (!query) {
+ const eventBus = eventBusRef.current
+ if (eventBus) {
+ eventBus.dispatch('findbarclose', { source: null })
+ }
+ setActiveMatch(0)
+ setTotalMatches(0)
+ return
+ }
+ if (isOpen) {
+ dispatchFind('')
+ }
+ }, [query, isOpen, dispatchFind, eventBusRef])
+
+ useEffect(() => {
+ const eventBus = eventBusRef.current
+ if (!eventBus || !isOpen) {
+ return
+ }
+ const handleMatchesCount = (evt: {
+ matchesCount: { current: number; total: number }
+ }): void => {
+ setActiveMatch(evt.matchesCount.current)
+ setTotalMatches(evt.matchesCount.total)
+ }
+ eventBus.on('updatefindmatchescount', handleMatchesCount)
+ return () => {
+ eventBus.off('updatefindmatchescount', handleMatchesCount)
+ }
+ }, [eventBusRef, isOpen])
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ e.stopPropagation()
+ if (e.key === 'Escape') {
+ onClose()
+ } else if (e.key === 'Enter' && e.shiftKey) {
+ findPrevious()
+ } else if (e.key === 'Enter') {
+ findNext()
+ }
+ },
+ [onClose, findNext, findPrevious]
+ )
+
+ if (!isOpen) {
+ return null
+ }
+
+ return (
+
+
setQuery(e.target.value)}
+ placeholder="Find in page..."
+ className="min-w-0 flex-1 border-none bg-transparent text-sm text-white outline-none placeholder:text-zinc-500"
+ />
+ {query ? (
+
+ {totalMatches > 0 ? `${activeMatch} of ${totalMatches}` : 'No matches'}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/renderer/src/components/editor/PdfViewer.tsx b/src/renderer/src/components/editor/PdfViewer.tsx
new file mode 100644
index 000000000..64ab97b5d
--- /dev/null
+++ b/src/renderer/src/components/editor/PdfViewer.tsx
@@ -0,0 +1,279 @@
+import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Image as ImageIcon, RotateCcw, Search, ZoomIn, ZoomOut } from 'lucide-react'
+import * as pdfjsLib from 'pdfjs-dist'
+import {
+ EventBus,
+ PDFFindController,
+ PDFLinkService,
+ PDFViewer as PdfJsViewer
+} from 'pdfjs-dist/web/pdf_viewer.mjs'
+import 'pdfjs-dist/web/pdf_viewer.css'
+import PdfFind from './PdfFind'
+
+import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl
+
+const MIN_SCALE = 0.25
+const MAX_SCALE = 5
+const SCALE_STEP = 1.25
+
+type PdfViewerProps = {
+ content: string
+ filePath: string
+}
+
+export default function PdfViewer({ content, filePath }: PdfViewerProps): JSX.Element {
+ const containerRef = useRef(null)
+ const viewerDivRef = useRef(null)
+ const [pdfError, setPdfError] = useState(null)
+ const [findOpen, setFindOpen] = useState(false)
+ const [scale, setScale] = useState(1)
+ const eventBusRef = useRef | null>(null)
+ const findControllerRef = useRef | null>(null)
+ const pdfViewerRef = useRef | null>(null)
+
+ const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
+ const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
+
+ useEffect(() => {
+ const container = containerRef.current
+ const viewerDiv = viewerDivRef.current
+ if (!container || !viewerDiv || !cleanedContent) {
+ return
+ }
+
+ setPdfError(null)
+ let cancelled = false
+ let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null
+
+ let binary: string
+ try {
+ binary = window.atob(cleanedContent)
+ } catch {
+ setPdfError('Failed to decode PDF content')
+ return
+ }
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+
+ const eventBus = new EventBus()
+ eventBusRef.current = eventBus
+
+ const linkService = new PDFLinkService({ eventBus })
+
+ const findController = new PDFFindController({ linkService, eventBus })
+ findControllerRef.current = findController
+
+ const viewer = new PdfJsViewer({
+ container,
+ viewer: viewerDiv,
+ eventBus,
+ linkService,
+ findController,
+ textLayerMode: 1,
+ removePageBorders: true
+ })
+ pdfViewerRef.current = viewer
+
+ linkService.setViewer(viewer)
+
+ eventBus.on('scalechanging', (evt: { scale: number }) => {
+ if (!cancelled) {
+ setScale(evt.scale)
+ }
+ })
+
+ const loadingTask = pdfjsLib.getDocument({ data: bytes })
+
+ loadingTask.promise
+ .then((doc) => {
+ if (cancelled) {
+ doc.destroy()
+ return
+ }
+ pdfDocument = doc
+ viewer.setDocument(doc)
+ linkService.setDocument(doc)
+ findController.setDocument(doc)
+ viewer.currentScaleValue = 'page-width'
+ })
+ .catch((err) => {
+ if (cancelled) {
+ return
+ }
+ if (err?.name === 'PasswordException') {
+ setPdfError('This PDF is password-protected')
+ } else {
+ setPdfError('Failed to load PDF preview')
+ }
+ })
+
+ return () => {
+ cancelled = true
+ setFindOpen(false)
+ loadingTask.destroy().catch(() => {})
+ if (pdfDocument) {
+ pdfDocument.destroy()
+ }
+ // Why: setDocument(null) is the proper teardown — it cancels active
+ // renders, clears the find controller, and dispatches pagesdestroy.
+ // The runtime accepts null but the types only declare PDFDocumentProxy.
+ viewer.setDocument(null as unknown as pdfjsLib.PDFDocumentProxy)
+ eventBusRef.current = null
+ findControllerRef.current = null
+ pdfViewerRef.current = null
+ }
+ }, [cleanedContent])
+
+ const closeFindBar = useCallback(() => {
+ setFindOpen(false)
+ }, [])
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent): void => {
+ const isMod = navigator.userAgent.includes('Mac') ? e.metaKey : e.ctrlKey
+ if (!isMod || e.altKey) {
+ return
+ }
+ if (!e.shiftKey && e.key.toLowerCase() === 'f') {
+ e.preventDefault()
+ e.stopPropagation()
+ setFindOpen(true)
+ }
+ if (e.key === '=' || e.key === '+') {
+ e.preventDefault()
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScale = Math.min(MAX_SCALE, viewer.currentScale * SCALE_STEP)
+ }
+ } else if (e.key === '-') {
+ e.preventDefault()
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScale = Math.max(MIN_SCALE, viewer.currentScale / SCALE_STEP)
+ }
+ } else if (e.key === '0') {
+ e.preventDefault()
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScaleValue = 'page-width'
+ }
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown, true)
+ return () => window.removeEventListener('keydown', handleKeyDown, true)
+ }, [])
+
+ const zoomIn = useCallback(() => {
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScale = Math.min(MAX_SCALE, viewer.currentScale * SCALE_STEP)
+ }
+ }, [])
+
+ const zoomOut = useCallback(() => {
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScale = Math.max(MIN_SCALE, viewer.currentScale / SCALE_STEP)
+ }
+ }, [])
+
+ const zoomReset = useCallback(() => {
+ const viewer = pdfViewerRef.current
+ if (viewer) {
+ viewer.currentScaleValue = 'page-width'
+ }
+ }, [])
+
+ const zoomPercent = Math.round(scale * 100)
+
+ if (pdfError) {
+ return (
+
+
+
+
{pdfError}
+
{filename}
+
+
+
+ {filename}
+
+ PDF preview
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Why: PDFViewer requires its container to be position:absolute.
+ The outer div uses all:revert to prevent Tailwind Preflight from
+ cascading into pdf.js DOM (text layer misalignment). The inner div
+ carries positioning and background since all:revert nullifies classes. */}
+
+
+
+
+
+
+
+
+
+
+ = MAX_SCALE}
+ title="Zoom in"
+ >
+
+
+ {zoomPercent}%
+
+
setFindOpen(true)}
+ title="Find in PDF (Cmd+F)"
+ >
+
+
+
+ {filename}
+
+
PDF preview
+
+
+ )
+}