/** Browser-native PDF preview with an always-available download fallback. */ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { downloadUrl, mediaUrl, type SessionScope } from './api.ts' import { t } from './locales.ts' import css from './sidebar.module.css' export function PdfView(props: { scope: SessionScope; path: string; title: string }) { const { scope, path, title } = props const [load, setLoad] = useState< | { status: 'loading' } | { status: 'ready'; url: string } | { status: 'error'; message: string } >({ status: 'loading' }) const [interactionBlocked, setInteractionBlocked] = useState(false) const frameRef = useRef(null) const shieldRef = useRef(null) useEffect(() => { const controller = new AbortController() let objectUrl: string | undefined setLoad({ status: 'loading' }) void (async () => { try { const response = await fetch(mediaUrl(scope, path), { signal: controller.signal }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const bytes = await response.arrayBuffer() if (controller.signal.aborted) return // A direct iframe navigation may download when an old host process or // proxy cached application/octet-stream. The Blob URL owns an explicit // PDF MIME and therefore consistently opens the browser PDF viewer. objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })) setLoad({ status: 'ready', url: objectUrl }) } catch (error) { if (controller.signal.aborted) return setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) }) } })() return () => { controller.abort() if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl) } // Granular scope fields: the scope object's identity churns, only its // sessionId / cwd fields gate the fetch. // eslint-disable-next-line react-hooks/exhaustive-deps }, [scope.sessionId, scope.cwd, path]) useEffect(() => { const block = (): void => { setInteractionBlocked(true) if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none' if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto' } const unblock = (): void => { setInteractionBlocked(false) if (frameRef.current !== null) frameRef.current.style.pointerEvents = '' if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none' } const blockForResize = (event: PointerEvent): void => { const target = event.target if (target instanceof Element && target.closest(`.${css.panelResize}, .${css.divider}`) !== null) { block() } } // An iframe is a separate browsing context and otherwise swallows the // dragover/pointer stream as soon as the cursor crosses it. Temporarily // remove it from hit-testing while a tab drag or pane resize is active. document.addEventListener('dragstart', block, true) document.addEventListener('dragend', unblock, true) document.addEventListener('drop', unblock, true) window.addEventListener('pointerdown', blockForResize, true) window.addEventListener('pointerup', unblock, true) window.addEventListener('pointercancel', unblock, true) window.addEventListener('blur', unblock) return () => { document.removeEventListener('dragstart', block, true) document.removeEventListener('dragend', unblock, true) document.removeEventListener('drop', unblock, true) window.removeEventListener('pointerdown', blockForResize, true) window.removeEventListener('pointerup', unblock, true) window.removeEventListener('pointercancel', unblock, true) window.removeEventListener('blur', unblock) } }, []) return (
{t('downloadToView')}
{load.status === 'loading' &&
{t('loading')}
} {load.status === 'error' &&
{load.message}
} {load.status === 'ready' && (