feat(editor): add Cmd+F find-in-page for PDF viewer (#899)

This commit is contained in:
Jinwoo Hong 2026-04-22 18:39:36 -04:00 committed by GitHub
parent 4860e9ab7a
commit 228699d48a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 685 additions and 128 deletions

View File

@ -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",

View File

@ -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: {}

View File

@ -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 <PdfViewer content={cleanedContent} filePath={filePath} />
}
if (imageError) {
return (
@ -95,133 +86,108 @@ export default function ImageViewer({
)
}
const previewPane = isPdf ? (
<div className="relative flex flex-1 flex-col overflow-auto">
{/* Why: Electron's Chromium PDF viewer can fail to initialize inside a
sandboxed iframe even when the Blob URL is valid. Using <embed> keeps
the preview isolated to the browser's native PDF surface without
depending on iframe document execution. */}
<embed
src={`${previewUrl}#navpanes=0`}
type={mimeType}
className="flex-1 min-h-[24rem] w-full bg-background"
/>
</div>
) : (
<div
className="flex flex-1 items-center justify-center overflow-auto bg-muted/20 p-4 cursor-pointer"
onClick={() => setIsPopupOpen(true)}
title="Open image in popup"
>
<div
className="flex items-center justify-center"
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
>
<img
src={previewUrl}
alt={filename}
className="max-h-full max-w-full object-contain"
onLoad={(event) => {
const img = event.currentTarget
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight })
}}
onError={() => setImageError(true)}
/>
</div>
</div>
)
return (
<>
<div className="flex h-full min-h-0 flex-col">
{previewPane}
<div
className="flex flex-1 items-center justify-center overflow-auto bg-muted/20 p-4 cursor-pointer"
onClick={() => setIsPopupOpen(true)}
title="Open image in popup"
>
<div
className="flex items-center justify-center"
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
>
<img
src={previewUrl}
alt={filename}
className="max-h-full max-w-full object-contain"
onLoad={(event) => {
const img = event.currentTarget
setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight })
}}
onError={() => setImageError(true)}
/>
</div>
</div>
<div className="flex items-center gap-4 border-t px-4 py-2 text-xs text-muted-foreground">
{!isPdf && (
<div className="flex items-center gap-1">
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom((prev) => Math.max(MIN_ZOOM, prev / ZOOM_STEP))}
disabled={zoom <= MIN_ZOOM}
title="Zoom out"
>
<ZoomOut size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom(1)}
disabled={zoom === 1}
title="Reset zoom"
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom((prev) => Math.min(MAX_ZOOM, prev * ZOOM_STEP))}
disabled={zoom >= MAX_ZOOM}
title="Zoom in"
>
<ZoomIn size={14} />
</button>
<span className="ml-1 tabular-nums">{zoomPercent}%</span>
</div>
)}
<div className="flex items-center gap-1">
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom((prev) => Math.max(MIN_ZOOM, prev / ZOOM_STEP))}
disabled={zoom <= MIN_ZOOM}
title="Zoom out"
>
<ZoomOut size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom(1)}
disabled={zoom === 1}
title="Reset zoom"
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={() => setZoom((prev) => Math.min(MAX_ZOOM, prev * ZOOM_STEP))}
disabled={zoom >= MAX_ZOOM}
title="Zoom in"
>
<ZoomIn size={14} />
</button>
<span className="ml-1 tabular-nums">{zoomPercent}%</span>
</div>
<span className="min-w-0 truncate" title={filename}>
{filename}
</span>
{!isPdf && imageDimensions && (
{imageDimensions && (
<span>
{imageDimensions.width} x {imageDimensions.height}
</span>
)}
{isPdf && <span>PDF preview</span>}
<span>{estimatedSize}</span>
</div>
</div>
{/* 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 && (
<Dialog open={isPopupOpen} onOpenChange={setIsPopupOpen}>
<DialogContent
showCloseButton={false}
className="top-1/2 left-1/2 h-[80vh] w-[70vw] max-w-[70vw] -translate-x-1/2 -translate-y-1/2 gap-0 overflow-hidden border border-border/60 bg-background p-0 shadow-2xl sm:max-w-[70vw]"
>
<DialogTitle className="sr-only">{filename}</DialogTitle>
<DialogDescription className="sr-only">Full-size image preview</DialogDescription>
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-3 py-2">
<div className="min-w-0 truncate text-sm font-medium text-foreground">{filename}</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setIsPopupOpen(false)}
>
<X size={14} />
<span>Close</span>
</button>
<Dialog open={isPopupOpen} onOpenChange={setIsPopupOpen}>
<DialogContent
showCloseButton={false}
className="top-1/2 left-1/2 h-[80vh] w-[70vw] max-w-[70vw] -translate-x-1/2 -translate-y-1/2 gap-0 overflow-hidden border border-border/60 bg-background p-0 shadow-2xl sm:max-w-[70vw]"
>
<DialogTitle className="sr-only">{filename}</DialogTitle>
<DialogDescription className="sr-only">Full-size image preview</DialogDescription>
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-3 py-2">
<div className="min-w-0 truncate text-sm font-medium text-foreground">{filename}</div>
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={() => setIsPopupOpen(false)}
>
<X size={14} />
<span>Close</span>
</button>
</div>
<div className="flex h-[calc(100%-4.5rem)] w-full min-h-0 items-center justify-center overflow-auto bg-muted/20 p-4">
<div
className="flex items-center justify-center"
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
>
<img
src={previewUrl}
alt={filename}
className="block max-h-full max-w-full object-contain"
/>
</div>
<div className="flex h-[calc(100%-4.5rem)] w-full min-h-0 items-center justify-center overflow-auto bg-muted/20 p-4">
<div
className="flex items-center justify-center"
style={{ transform: `scale(${zoom})`, transformOrigin: 'center center' }}
>
<img
src={previewUrl}
alt={filename}
className="block max-h-full max-w-full object-contain"
/>
</div>
</div>
<div className="flex items-center justify-between border-t border-border/60 bg-background/95 px-3 py-2 text-xs text-muted-foreground">
<div>Press Esc to close</div>
<div className="tabular-nums">{zoomPercent}%</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="flex items-center justify-between border-t border-border/60 bg-background/95 px-3 py-2 text-xs text-muted-foreground">
<div>Press Esc to close</div>
<div className="tabular-nums">{zoomPercent}%</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -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<InstanceType<typeof EventBus> | null>
}
export default function PdfFind({
isOpen,
onClose,
eventBusRef
}: PdfFindProps): React.JSX.Element | null {
const inputRef = useRef<HTMLInputElement>(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 (
<div
className="absolute top-2 right-2 z-50 flex items-center gap-1 rounded-lg border border-zinc-700 bg-zinc-800/95 px-2 py-1 shadow-lg backdrop-blur-sm"
style={{ width: 300 }}
onKeyDown={handleKeyDown}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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 ? (
<span className="shrink-0 text-xs text-zinc-400">
{totalMatches > 0 ? `${activeMatch} of ${totalMatches}` : 'No matches'}
</span>
) : null}
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={findPrevious}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Previous match"
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={findNext}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Next match"
>
<ChevronDown size={14} />
</Button>
<div className="mx-0.5 h-4 w-px bg-zinc-700" />
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onClose}
className="flex size-6 shrink-0 items-center justify-center rounded text-zinc-400 hover:text-zinc-200"
title="Close"
>
<X size={14} />
</Button>
</div>
)
}

View File

@ -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<HTMLDivElement>(null)
const viewerDivRef = useRef<HTMLDivElement>(null)
const [pdfError, setPdfError] = useState<string | null>(null)
const [findOpen, setFindOpen] = useState(false)
const [scale, setScale] = useState(1)
const eventBusRef = useRef<InstanceType<typeof EventBus> | null>(null)
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | 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 (
<div className="flex h-full flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-3 bg-muted/20 p-8 text-sm text-muted-foreground">
<ImageIcon size={40} />
<div>{pdfError}</div>
<div className="max-w-md break-all text-center text-xs">{filename}</div>
</div>
<div className="flex items-center gap-4 border-t px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 truncate" title={filename}>
{filename}
</span>
<span>PDF preview</span>
</div>
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col">
<div className="relative flex flex-1 flex-col overflow-hidden">
<PdfFind isOpen={findOpen} onClose={closeFindBar} eventBusRef={eventBusRef} />
{/* 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. */}
<div style={{ all: 'revert' }}>
<div
ref={containerRef}
style={{
position: 'absolute',
inset: '0',
overflow: 'auto',
background: 'var(--pdf-viewer-bg, #e4e4e7)'
}}
className="dark:[--pdf-viewer-bg:#18181b]"
>
<div ref={viewerDivRef} className="pdfViewer" />
</div>
</div>
</div>
<div className="flex items-center gap-4 border-t px-4 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={zoomOut}
disabled={scale <= MIN_SCALE}
title="Zoom out"
>
<ZoomOut size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground"
onClick={zoomReset}
title="Fit to width"
>
<RotateCcw size={14} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground disabled:opacity-50"
onClick={zoomIn}
disabled={scale >= MAX_SCALE}
title="Zoom in"
>
<ZoomIn size={14} />
</button>
<span className="ml-1 tabular-nums">{zoomPercent}%</span>
</div>
<button
type="button"
className="rounded p-1 hover:bg-accent hover:text-foreground"
onClick={() => setFindOpen(true)}
title="Find in PDF (Cmd+F)"
>
<Search size={14} />
</button>
<span className="min-w-0 truncate" title={filename}>
{filename}
</span>
<span>PDF preview</span>
</div>
</div>
)
}