feat: preview pdf files in app
This commit is contained in:
@@ -157,9 +157,9 @@ interface VaultEntry {
|
||||
|---|---|---|
|
||||
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Note Content Freshness
|
||||
|
||||
|
||||
@@ -193,8 +193,8 @@ flowchart TD
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
28
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
28
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0098"
|
||||
title: "In-app image and PDF previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0086"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.**
|
||||
|
||||
- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`.
|
||||
- Supported images render with `<img>` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`.
|
||||
- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch.
|
||||
- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable.
|
||||
- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PDFs do not become notes and do not get Markdown editor semantics.
|
||||
- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape.
|
||||
- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions.
|
||||
@@ -149,3 +149,4 @@ proposed → active → superseded
|
||||
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
|
||||
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
|
||||
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
|
||||
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type FilePreviewKind = 'image' | 'pdf'
|
||||
|
||||
const IMAGE_PREVIEW_EXTENSIONS = new Set([
|
||||
'apng',
|
||||
'avif',
|
||||
@@ -14,6 +16,7 @@ const IMAGE_PREVIEW_EXTENSIONS = new Set([
|
||||
'tiff',
|
||||
'webp',
|
||||
])
|
||||
const PDF_PREVIEW_EXTENSIONS = new Set(['pdf'])
|
||||
|
||||
function extensionFromFilename(filename: string): string | null {
|
||||
const lastSegment = filename.split(/[\\/]/u).pop() ?? filename
|
||||
@@ -27,9 +30,25 @@ export function previewExtension(entry: Pick<VaultEntry, 'filename' | 'path'>):
|
||||
}
|
||||
|
||||
export function isImagePreviewEntry(entry: Pick<VaultEntry, 'fileKind' | 'filename' | 'path'>): boolean {
|
||||
if (entry.fileKind !== 'binary') return false
|
||||
return filePreviewKind(entry) === 'image'
|
||||
}
|
||||
|
||||
export function isPdfPreviewEntry(entry: Pick<VaultEntry, 'fileKind' | 'filename' | 'path'>): boolean {
|
||||
return filePreviewKind(entry) === 'pdf'
|
||||
}
|
||||
|
||||
export function filePreviewKind(entry: Pick<VaultEntry, 'fileKind' | 'filename' | 'path'>): FilePreviewKind | null {
|
||||
if (entry.fileKind && entry.fileKind !== 'binary') return null
|
||||
|
||||
const extension = previewExtension(entry)
|
||||
return extension ? IMAGE_PREVIEW_EXTENSIONS.has(extension) : false
|
||||
if (!extension) return null
|
||||
if (IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image'
|
||||
if (PDF_PREVIEW_EXTENSIONS.has(extension)) return 'pdf'
|
||||
return null
|
||||
}
|
||||
|
||||
export function isFilePreviewEntry(entry: Pick<VaultEntry, 'fileKind' | 'filename' | 'path'>): boolean {
|
||||
return filePreviewKind(entry) !== null
|
||||
}
|
||||
|
||||
export function previewFileTypeLabel(entry: Pick<VaultEntry, 'filename' | 'path'>): string {
|
||||
|
||||
Reference in New Issue
Block a user