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" - > -
- {filename} { - const img = event.currentTarget - setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight }) - }} - onError={() => setImageError(true)} - /> -
-
- ) - return ( <>
- {previewPane} +
setIsPopupOpen(true)} + title="Open image in popup" + > +
+ {filename} { + const img = event.currentTarget + setImageDimensions({ width: img.naturalWidth, height: img.naturalHeight }) + }} + onError={() => setImageError(true)} + /> +
+
- {!isPdf && ( -
- - - - {zoomPercent}% -
- )} +
+ + + + {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}
- + + + {filename} + Full-size image preview +
+
{filename}
+ +
+
+
+ {filename}
-
-
- {filename} -
-
-
-
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. */} +
+
+
+
+
+
+
+
+ + + + {zoomPercent}% +
+ + + {filename} + + PDF preview +
+
+ ) +}