feat: preview pdf files in app
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user