feat(editor): add Cmd+F find-in-page for PDF viewer (#899)
This commit is contained in:
parent
4860e9ab7a
commit
228699d48a
|
|
@ -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",
|
||||
|
|
|
|||
141
pnpm-lock.yaml
141
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: {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue