feat: preview pdf files in app

This commit is contained in:
lucaronin
2026-04-29 13:14:44 +02:00
parent 5b86b0b50d
commit 23145a05f5
10 changed files with 239 additions and 53 deletions

View File

@@ -284,6 +284,28 @@ describe('Editor', () => {
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
})
it('renders an in-app PDF preview for binary PDF tabs', () => {
const pdfEntry: VaultEntry = {
...mockEntry,
path: '/vault/assets/report.pdf',
filename: 'report.pdf',
title: 'report.pdf',
fileKind: 'binary',
}
renderEditor({
tabs: [{ entry: pdfEntry, content: '' }],
activeTabPath: pdfEntry.path,
entries: [pdfEntry],
})
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute(
'data',
'asset://localhost/%2Fvault%2Fassets%2Freport.pdf',
)
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
})
it('shows a graceful fallback when an image preview fails to render', () => {
const imageEntry: VaultEntry = {
...mockEntry,

View File

@@ -40,6 +40,12 @@ const imageEntry: VaultEntry = {
hasH1: false,
fileKind: 'binary',
}
const pdfEntry: VaultEntry = {
...imageEntry,
path: '/vault/Attachments/report.pdf',
filename: 'report.pdf',
title: 'report.pdf',
}
describe('FilePreview', () => {
it('routes header file actions to the active file path', () => {
@@ -64,4 +70,24 @@ describe('FilePreview', () => {
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png')
expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png')
})
it('renders supported PDF files through the asset preview path', () => {
render(<FilePreview entry={pdfEntry} />)
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
expect(screen.getByText('PDF file')).toBeInTheDocument()
})
it('renders supported PDFs when binary metadata is unavailable', () => {
render(<FilePreview entry={{ ...pdfEntry, fileKind: undefined }} />)
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
})
it('provides a graceful fallback when a PDF preview cannot render', () => {
render(<FilePreview entry={pdfEntry} />)
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('PDF preview failed')
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
})
})

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState, type KeyboardEvent } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types'
import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview'
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
import { openLocalFile } from '../utils/url'
import { Button } from './ui/button'
@@ -21,6 +21,42 @@ interface FilePreviewFallbackProps {
onOpenExternal: () => void
}
function fallbackContentForPreviewKind(previewKind: FilePreviewKind | null): Omit<FilePreviewFallbackProps, 'onOpenExternal'> {
if (previewKind === 'image') {
return {
icon: 'warning',
title: 'Image preview failed',
description: 'Tolaria could not render this image file in the preview.',
}
}
if (previewKind === 'pdf') {
return {
icon: 'warning',
title: 'PDF preview failed',
description: 'Tolaria could not render this PDF file in the preview.',
}
}
return {
icon: 'file',
title: 'Preview unavailable',
description: 'Tolaria does not have an in-app preview for this file type.',
}
}
function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind | null }) {
if (previewKind === 'image') {
return <ImageSquare size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
if (previewKind === 'pdf') {
return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
function FilePreviewFallback({ icon, title, description, onOpenExternal }: FilePreviewFallbackProps) {
const Icon = icon === 'warning' ? WarningCircle : FileDashed
@@ -44,28 +80,26 @@ function FilePreviewFallback({ icon, title, description, onOpenExternal }: FileP
function FilePreviewHeader({
entry,
isImage,
previewKind,
fileTypeLabel,
onOpenExternal,
onRevealFile,
onCopyFilePath,
}: {
entry: VaultEntry
isImage: boolean
previewKind: FilePreviewKind | null
fileTypeLabel: string
onOpenExternal: () => void
onRevealFile?: () => void
onCopyFilePath?: () => void
}) {
const HeaderIcon = isImage ? ImageSquare : FileDashed
return (
<div
className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4"
data-tauri-drag-region
>
<div className="flex min-w-0 items-center gap-2">
<HeaderIcon size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
<FilePreviewHeaderIcon previewKind={previewKind} />
<div className="min-w-0">
<h1 className="m-0 truncate text-[14px] font-semibold text-foreground">{entry.title}</h1>
<p className="m-0 text-[11px] text-muted-foreground">{fileTypeLabel}</p>
@@ -93,6 +127,35 @@ function FilePreviewHeader({
)
}
function FilePreviewPdf({
entry,
pdfSrc,
onOpenExternal,
}: {
entry: VaultEntry
pdfSrc: string
onOpenExternal: () => void
}) {
const fallback = fallbackContentForPreviewKind('pdf')
return (
<object
data={pdfSrc}
type="application/pdf"
title={entry.title}
className="h-full min-h-[320px] w-full bg-background"
data-testid="pdf-file-preview"
>
<FilePreviewFallback
icon={fallback.icon}
title={fallback.title}
description={fallback.description}
onOpenExternal={onOpenExternal}
/>
</object>
)
}
function FilePreviewImage({
entry,
imageSrc,
@@ -121,32 +184,34 @@ function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, ima
function FilePreviewBody({
entry,
isImage,
imageSrc,
previewKind,
assetSrc,
imageFailed,
onImageError,
onOpenExternal,
}: {
entry: VaultEntry
isImage: boolean
imageSrc: string | null
previewKind: FilePreviewKind | null
assetSrc: string | null
imageFailed: boolean
onImageError: () => void
onOpenExternal: () => void
}) {
if (shouldRenderImagePreview(isImage, imageSrc, imageFailed)) {
return <FilePreviewImage entry={entry} imageSrc={imageSrc} onImageError={onImageError} />
if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) {
return <FilePreviewImage entry={entry} imageSrc={assetSrc} onImageError={onImageError} />
}
if (previewKind === 'pdf' && assetSrc !== null) {
return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} />
}
const fallback = fallbackContentForPreviewKind(previewKind)
return (
<FilePreviewFallback
icon={isImage ? 'warning' : 'file'}
title={isImage ? 'Image preview failed' : 'Preview unavailable'}
description={
isImage
? 'Tolaria could not render this image file in the preview.'
: 'Tolaria does not have an in-app preview for this file type.'
}
icon={fallback.icon}
title={fallback.title}
description={fallback.description}
onOpenExternal={onOpenExternal}
/>
)
@@ -158,11 +223,12 @@ export function FilePreview({
onOpenExternalFile,
onRevealFile,
}: FilePreviewProps) {
const [imageFailed, setImageFailed] = useState(false)
const isImage = isImagePreviewEntry(entry)
const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage])
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
const previewKind = filePreviewKind(entry)
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
const fileTypeLabel = previewFileTypeLabel(entry)
const handleImageError = useCallback(() => setImageFailed(true), [])
const imageFailed = failedImagePath === entry.path
const handleImageError = useCallback(() => setFailedImagePath(entry.path), [entry.path])
const handleOpenExternal = useCallback(() => {
if (onOpenExternalFile) {
@@ -200,7 +266,7 @@ export function FilePreview({
>
<FilePreviewHeader
entry={entry}
isImage={isImage}
previewKind={previewKind}
fileTypeLabel={fileTypeLabel}
onOpenExternal={handleOpenExternal}
onRevealFile={onRevealFile ? handleRevealFile : undefined}
@@ -209,8 +275,8 @@ export function FilePreview({
<div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody
entry={entry}
isImage={isImage}
imageSrc={imageSrc}
previewKind={previewKind}
assetSrc={assetSrc}
imageFailed={imageFailed}
onImageError={handleImageError}
onOpenExternal={handleOpenExternal}

View File

@@ -58,6 +58,26 @@ describe('NoteItem', () => {
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image')
})
it('renders PDF files as clickable rows with a PDF file indicator', () => {
const pdfEntry = makeEntry({
path: '/vault/reports/brief.pdf',
filename: 'brief.pdf',
title: 'brief.pdf',
fileKind: 'binary',
})
const onClickNote = vi.fn()
render(<NoteItem entry={pdfEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
const item = screen.getByTestId('pdf-file-item')
expect(item.className).not.toContain('opacity-50')
expect(item).toHaveAttribute('title', 'Open PDF preview')
fireEvent.click(item)
expect(onClickNote).toHaveBeenCalledWith(pdfEntry, expect.any(Object))
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf')
})
it('renders text files as clickable rows', () => {
const textEntry = makeEntry({
path: '/vault/config.yml',

View File

@@ -4,12 +4,12 @@ import { cn } from '@/lib/utils'
import {
Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple,
File, FileDashed, ImageSquare,
File, FileDashed, FilePdf, ImageSquare,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { isImagePreviewEntry } from '../utils/filePreview'
import { filePreviewKind, type FilePreviewKind } from '../utils/filePreview'
import { NoteTitleIcon } from './NoteTitleIcon'
import { PropertyChips } from './note-item/PropertyChips'
import { ChangeNoteContent } from './note-item/ChangeNoteContent'
@@ -109,7 +109,7 @@ function NoteTypeIndicator({
}: {
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
typeColor: string
filePreviewKind?: 'image'
filePreviewKind?: FilePreviewKind
}) {
return (
<TypeIcon
@@ -202,7 +202,9 @@ function InteractiveNoteDetails({
}
function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
if (isImagePreviewEntry(entry)) return ImageSquare
const previewKind = filePreviewKind(entry)
if (previewKind === 'image') return ImageSquare
if (previewKind === 'pdf') return FilePdf
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, customIcon)
}
@@ -232,11 +234,11 @@ function StandardNoteContent({
}) {
const te = typeEntryMap[entry.isA ?? '']
const TypeIcon = resolveNoteTypeIcon(entry, te?.icon)
const filePreviewKind = isImagePreviewEntry(entry) ? 'image' : undefined
const previewKind = filePreviewKind(entry) ?? undefined
return (
<>
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={filePreviewKind} />
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={previewKind} />
<div className="space-y-2" data-testid="note-content-stack">
{isBinary ? (
<NoteTitleRow
@@ -360,30 +362,31 @@ function resolveNoteItemSurfaceStyle({
function resolveNoteItemTestId({
isMultiSelected,
isImagePreview,
previewKind,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isMultiSelected' | 'isUnavailableBinary'> & {
isImagePreview: boolean
previewKind: FilePreviewKind | null
}) {
if (isMultiSelected) return 'multi-selected-item'
if (isImagePreview) return 'image-file-item'
if (previewKind) return `${previewKind}-file-item`
return isUnavailableBinary ? 'binary-file-item' : undefined
}
function resolveNoteItemTitle({
isImagePreview,
previewKind,
isUnavailableBinary,
}: Pick<NoteItemVisualState, 'isUnavailableBinary'> & {
isImagePreview: boolean
previewKind: FilePreviewKind | null
}) {
if (isImagePreview) return 'Open image preview'
if (previewKind === 'image') return 'Open image preview'
if (previewKind === 'pdf') return 'Open PDF preview'
return isUnavailableBinary ? 'Cannot open this file type' : undefined
}
function resolveNoteItemSurfaceProps({
entry,
isUnavailableBinary,
isImagePreview,
previewKind,
isSelected,
isMultiSelected,
isHighlighted,
@@ -394,7 +397,7 @@ function resolveNoteItemSurfaceProps({
typeLightColor,
}: NoteItemVisualState & {
entry: VaultEntry
isImagePreview: boolean
previewKind: FilePreviewKind | null
onClickNote: NoteItemProps['onClickNote']
onPrefetch?: NoteItemProps['onPrefetch']
onContextMenu?: NoteItemProps['onContextMenu']
@@ -407,8 +410,8 @@ function resolveNoteItemSurfaceProps({
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
testId: resolveNoteItemTestId({ isMultiSelected, isImagePreview, isUnavailableBinary }),
title: resolveNoteItemTitle({ isImagePreview, isUnavailableBinary }),
testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }),
title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }),
}
}
@@ -497,16 +500,17 @@ function NoteItemContent({
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
const isBinary = entry.fileKind === 'binary'
const isImagePreview = isImagePreviewEntry(entry)
const isUnavailableBinary = isBinary && !isImagePreview
const previewKind = filePreviewKind(entry)
const isPreviewableFile = previewKind !== null
const isUnavailableBinary = isBinary && !isPreviewableFile
const te = typeEntryMap[entry.isA ?? '']
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
const typeColor = isImagePreview ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeColor = isPreviewableFile ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const surfaceProps = resolveNoteItemSurfaceProps({
entry,
isUnavailableBinary,
isImagePreview,
previewKind,
isSelected,
isMultiSelected,
isHighlighted,