fix(preview): allow native media playback

This commit is contained in:
lucaronin
2026-05-05 01:08:33 +02:00
parent f5adbf9cf7
commit d347cda9e3
9 changed files with 256 additions and 44 deletions

View File

@@ -160,9 +160,9 @@ interface VaultEntry {
|---|---|---| |---|---|---|
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync | | `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 | | `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 and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback | | `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
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. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. 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>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### Note Content Freshness ### Note Content Freshness

View File

@@ -207,8 +207,8 @@ flowchart TD
``` ```
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; 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, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, 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: Type` document; new type documents created by Tolaria are written at the vault root. - **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; 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, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, 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: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (220-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. - **Note List / Pulse View** (220-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 media 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, rich-editor width toggle, and the secondary-overflow Table of Contents action, 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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. - **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, 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 rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `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.
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50). - **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties 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. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`. Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.

View File

@@ -40,7 +40,7 @@
"style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com", "style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com",
"style-src-attr": "'unsafe-inline'", "style-src-attr": "'unsafe-inline'",
"font-src": "'self' data: https://fonts.gstatic.com", "font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:", "media-src": "'self' asset: http://asset.localhost data: blob: https:",
"object-src": "'self' asset: http://asset.localhost" "object-src": "'self' asset: http://asset.localhost"
}, },
"assetProtocol": { "assetProtocol": {

View File

@@ -54,6 +54,18 @@ const pdfEntry: VaultEntry = {
filename: 'report.pdf', filename: 'report.pdf',
title: 'report.pdf', title: 'report.pdf',
} }
const audioEntry: VaultEntry = {
...imageEntry,
path: '/vault/Attachments/meeting.mp3',
filename: 'meeting.mp3',
title: 'meeting.mp3',
}
const videoEntry: VaultEntry = {
...imageEntry,
path: '/vault/Attachments/demo.mp4',
filename: 'demo.mp4',
title: 'demo.mp4',
}
describe('FilePreview', () => { describe('FilePreview', () => {
beforeEach(() => { beforeEach(() => {
@@ -110,6 +122,22 @@ describe('FilePreview', () => {
expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf') expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf')
}) })
it('renders supported audio files through the media asset path', () => {
render(<FilePreview entry={audioEntry} />)
expect(screen.getByTestId('audio-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/meeting.mp3')
expect(screen.getByText('MP3 file')).toBeInTheDocument()
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'audio' })
})
it('renders supported video files through the media asset path', () => {
render(<FilePreview entry={videoEntry} />)
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('src', 'asset:///vault/Attachments/demo.mp4')
expect(screen.getByTestId('video-file-preview')).toHaveAttribute('title', 'demo.mp4')
expect(trackEventMock).toHaveBeenCalledWith('file_preview_opened', { preview_kind: 'video' })
})
it('provides a graceful fallback when a PDF preview cannot render', () => { it('provides a graceful fallback when a PDF preview cannot render', () => {
render(<FilePreview entry={pdfEntry} />) render(<FilePreview entry={pdfEntry} />)
@@ -128,4 +156,16 @@ describe('FilePreview', () => {
expect.objectContaining({ path: expect.any(String) }), expect.objectContaining({ path: expect.any(String) }),
) )
}) })
it('tracks media preview failures without leaking the file path', () => {
render(<FilePreview entry={audioEntry} />)
fireEvent.error(screen.getByTestId('audio-file-preview'))
expect(trackEventMock).toHaveBeenCalledWith('file_preview_failed', { preview_kind: 'audio' })
expect(trackEventMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ path: expect.any(String) }),
)
})
}) })

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react' import { useCallback, useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from 'react'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react' import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, SpeakerHigh, Video, WarningCircle } from '@phosphor-icons/react'
import type { VaultEntry } from '../types' import type { VaultEntry } from '../types'
import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics' import { trackFilePreviewAction, trackFilePreviewFailed, trackFilePreviewOpened } from '../lib/productAnalytics'
import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview' import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview'
@@ -55,6 +55,14 @@ function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind |
return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" /> return <FilePdf size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
} }
if (previewKind === 'audio') {
return <SpeakerHigh size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
if (previewKind === 'video') {
return <Video size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
}
return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" /> return <FileDashed size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
} }
@@ -179,6 +187,61 @@ function FilePreviewImage({
) )
} }
function FilePreviewMediaFrame({
children,
video = false,
}: {
children: ReactNode
video?: boolean
}) {
return (
<div className={`flex h-full items-center justify-center ${video ? 'min-h-[320px] bg-black p-4' : 'min-h-[260px] p-6'}`}>
{children}
</div>
)
}
function FilePreviewMedia({
entry,
mediaKind,
mediaSrc,
onMediaError,
}: {
entry: VaultEntry
mediaKind: 'audio' | 'video'
mediaSrc: string
onMediaError: () => void
}) {
if (mediaKind === 'audio') {
return (
<FilePreviewMediaFrame>
<audio
controls
preload="metadata"
src={mediaSrc}
className="w-full max-w-2xl"
data-testid="audio-file-preview"
onError={onMediaError}
/>
</FilePreviewMediaFrame>
)
}
return (
<FilePreviewMediaFrame video>
<video
controls
preload="metadata"
src={mediaSrc}
title={entry.title}
className="max-h-full max-w-full"
data-testid="video-file-preview"
onError={onMediaError}
/>
</FilePreviewMediaFrame>
)
}
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string { function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
return isImage && imageSrc !== null && !imageFailed return isImage && imageSrc !== null && !imageFailed
} }
@@ -189,6 +252,8 @@ function FilePreviewBody({
assetSrc, assetSrc,
imageFailed, imageFailed,
onImageError, onImageError,
onAudioError,
onVideoError,
onOpenExternal, onOpenExternal,
}: { }: {
entry: VaultEntry entry: VaultEntry
@@ -196,6 +261,8 @@ function FilePreviewBody({
assetSrc: string | null assetSrc: string | null
imageFailed: boolean imageFailed: boolean
onImageError: () => void onImageError: () => void
onAudioError: () => void
onVideoError: () => void
onOpenExternal: () => void onOpenExternal: () => void
}) { }) {
if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) { if (shouldRenderImagePreview(previewKind === 'image', assetSrc, imageFailed)) {
@@ -206,6 +273,14 @@ function FilePreviewBody({
return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} /> return <FilePreviewPdf entry={entry} pdfSrc={assetSrc} onOpenExternal={onOpenExternal} />
} }
if (previewKind === 'audio' && assetSrc !== null) {
return <FilePreviewMedia entry={entry} mediaKind="audio" mediaSrc={assetSrc} onMediaError={onAudioError} />
}
if (previewKind === 'video' && assetSrc !== null) {
return <FilePreviewMedia entry={entry} mediaKind="video" mediaSrc={assetSrc} onMediaError={onVideoError} />
}
const fallback = fallbackContentForPreviewKind(previewKind) const fallback = fallbackContentForPreviewKind(previewKind)
return ( return (
@@ -218,48 +293,96 @@ function FilePreviewBody({
) )
} }
function useFilePreviewFailureState(entryPath: string) {
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
const [failedMediaPath, setFailedMediaPath] = useState<string | null>(null)
const handleImageError = useCallback(() => {
setFailedImagePath(entryPath)
trackFilePreviewFailed('image')
}, [entryPath])
const handleAudioError = useCallback(() => {
setFailedMediaPath(entryPath)
trackFilePreviewFailed('audio')
}, [entryPath])
const handleVideoError = useCallback(() => {
setFailedMediaPath(entryPath)
trackFilePreviewFailed('video')
}, [entryPath])
return {
imageFailed: failedImagePath === entryPath,
mediaFailed: failedMediaPath === entryPath,
handleImageError,
handleAudioError,
handleVideoError,
}
}
function useFilePreviewActions({
entryPath,
onCopyFilePath,
onOpenExternalFile,
onRevealFile,
previewKind,
}: {
entryPath: string
onCopyFilePath?: (path: string) => void
onOpenExternalFile?: (path: string) => void
onRevealFile?: (path: string) => void
previewKind: FilePreviewKind | null
}) {
const handleOpenExternal = useCallback(() => {
trackFilePreviewAction('open_external', previewKind)
if (onOpenExternalFile) {
onOpenExternalFile(entryPath)
return
}
void openLocalFile(entryPath).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entryPath, onOpenExternalFile, previewKind])
const handleRevealFile = useCallback(() => {
trackFilePreviewAction('reveal', previewKind)
onRevealFile?.(entryPath)
}, [entryPath, onRevealFile, previewKind])
const handleCopyFilePath = useCallback(() => {
trackFilePreviewAction('copy_path', previewKind)
onCopyFilePath?.(entryPath)
}, [entryPath, onCopyFilePath, previewKind])
return { handleOpenExternal, handleRevealFile, handleCopyFilePath }
}
function previewKindForBody(previewKind: FilePreviewKind | null, mediaFailed: boolean): FilePreviewKind | null {
return mediaFailed ? null : previewKind
}
export function FilePreview({ export function FilePreview({
entry, entry,
onCopyFilePath, onCopyFilePath,
onOpenExternalFile, onOpenExternalFile,
onRevealFile, onRevealFile,
}: FilePreviewProps) { }: FilePreviewProps) {
const [failedImagePath, setFailedImagePath] = useState<string | null>(null)
const previewKind = filePreviewKind(entry) const previewKind = filePreviewKind(entry)
const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind]) const assetSrc = useMemo(() => (previewKind ? convertFileSrc(entry.path) : null), [entry.path, previewKind])
const fileTypeLabel = previewFileTypeLabel(entry) const fileTypeLabel = previewFileTypeLabel(entry)
const imageFailed = failedImagePath === entry.path const failures = useFilePreviewFailureState(entry.path)
const handleImageError = useCallback(() => { const actions = useFilePreviewActions({
setFailedImagePath(entry.path) entryPath: entry.path,
trackFilePreviewFailed('image') onCopyFilePath,
}, [entry.path]) onOpenExternalFile,
onRevealFile,
previewKind,
})
useEffect(() => { useEffect(() => {
trackFilePreviewOpened(previewKind) trackFilePreviewOpened(previewKind)
}, [entry.path, previewKind]) }, [entry.path, previewKind])
const handleOpenExternal = useCallback(() => {
trackFilePreviewAction('open_external', previewKind)
if (onOpenExternalFile) {
onOpenExternalFile(entry.path)
return
}
void openLocalFile(entry.path).catch((error) => {
console.warn('Failed to open file with default app:', error)
})
}, [entry.path, onOpenExternalFile, previewKind])
const handleRevealFile = useCallback(() => {
trackFilePreviewAction('reveal', previewKind)
onRevealFile?.(entry.path)
}, [entry.path, onRevealFile, previewKind])
const handleCopyFilePath = useCallback(() => {
trackFilePreviewAction('copy_path', previewKind)
onCopyFilePath?.(entry.path)
}, [entry.path, onCopyFilePath, previewKind])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => { const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
event.preventDefault() event.preventDefault()
@@ -279,18 +402,20 @@ export function FilePreview({
entry={entry} entry={entry}
previewKind={previewKind} previewKind={previewKind}
fileTypeLabel={fileTypeLabel} fileTypeLabel={fileTypeLabel}
onOpenExternal={handleOpenExternal} onOpenExternal={actions.handleOpenExternal}
onRevealFile={onRevealFile ? handleRevealFile : undefined} onRevealFile={onRevealFile ? actions.handleRevealFile : undefined}
onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined} onCopyFilePath={onCopyFilePath ? actions.handleCopyFilePath : undefined}
/> />
<div className="min-h-0 flex-1 overflow-auto bg-background"> <div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody <FilePreviewBody
entry={entry} entry={entry}
previewKind={previewKind} previewKind={previewKindForBody(previewKind, failures.mediaFailed)}
assetSrc={assetSrc} assetSrc={assetSrc}
imageFailed={imageFailed} imageFailed={failures.imageFailed}
onImageError={handleImageError} onImageError={failures.handleImageError}
onOpenExternal={handleOpenExternal} onAudioError={failures.handleAudioError}
onVideoError={failures.handleVideoError}
onOpenExternal={actions.handleOpenExternal}
/> />
</div> </div>
</section> </section>

View File

@@ -78,6 +78,37 @@ describe('NoteItem', () => {
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf') expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'pdf')
}) })
it('renders audio and video files as clickable media preview rows', () => {
const audioEntry = makeEntry({
path: '/vault/attachments/interview.mp3',
filename: 'interview.mp3',
title: 'interview.mp3',
fileKind: 'binary',
})
const videoEntry = makeEntry({
path: '/vault/attachments/demo.mp4',
filename: 'demo.mp4',
title: 'demo.mp4',
fileKind: 'binary',
})
const onClickNote = vi.fn()
render(
<>
<NoteItem entry={audioEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
<NoteItem entry={videoEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />
</>,
)
expect(screen.getByTestId('audio-file-item')).toHaveAttribute('title', 'Open audio preview')
expect(screen.getByTestId('video-file-item')).toHaveAttribute('title', 'Open video preview')
fireEvent.click(screen.getByTestId('audio-file-item'))
fireEvent.click(screen.getByTestId('video-file-item'))
expect(onClickNote).toHaveBeenCalledWith(audioEntry, expect.any(Object))
expect(onClickNote).toHaveBeenCalledWith(videoEntry, expect.any(Object))
})
it('renders text files as clickable rows', () => { it('renders text files as clickable rows', () => {
const textEntry = makeEntry({ const textEntry = makeEntry({
path: '/vault/config.yml', path: '/vault/config.yml',

View File

@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils'
import { import {
Wrench, Flask, Target, ArrowsClockwise, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, FileText, StackSimple, Users, CalendarBlank, Tag, FileText, StackSimple,
File, FileDashed, FilePdf, ImageSquare, File, FileDashed, FilePdf, ImageSquare, SpeakerHigh, Video,
} from '@phosphor-icons/react' } from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors' import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { resolveIcon } from '../utils/iconRegistry' import { resolveIcon } from '../utils/iconRegistry'
@@ -205,6 +205,8 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
const previewKind = filePreviewKind(entry) const previewKind = filePreviewKind(entry)
if (previewKind === 'image') return ImageSquare if (previewKind === 'image') return ImageSquare
if (previewKind === 'pdf') return FilePdf if (previewKind === 'pdf') return FilePdf
if (previewKind === 'audio') return SpeakerHigh
if (previewKind === 'video') return Video
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind) if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, customIcon) return getTypeIcon(entry.isA, customIcon)
} }
@@ -380,6 +382,8 @@ function resolveNoteItemTitle({
}) { }) {
if (previewKind === 'image') return 'Open image preview' if (previewKind === 'image') return 'Open image preview'
if (previewKind === 'pdf') return 'Open PDF preview' if (previewKind === 'pdf') return 'Open PDF preview'
if (previewKind === 'audio') return 'Open audio preview'
if (previewKind === 'video') return 'Open video preview'
return isUnavailableBinary ? 'Cannot open this file type' : undefined return isUnavailableBinary ? 'Cannot open this file type' : undefined
} }

View File

@@ -1,6 +1,6 @@
import type { VaultEntry } from '../types' import type { VaultEntry } from '../types'
export type FilePreviewKind = 'image' | 'pdf' export type FilePreviewKind = 'image' | 'pdf' | 'audio' | 'video'
const IMAGE_PREVIEW_EXTENSIONS = new Set([ const IMAGE_PREVIEW_EXTENSIONS = new Set([
'apng', 'apng',
@@ -17,6 +17,8 @@ const IMAGE_PREVIEW_EXTENSIONS = new Set([
'webp', 'webp',
]) ])
const PDF_PREVIEW_EXTENSIONS = new Set(['pdf']) const PDF_PREVIEW_EXTENSIONS = new Set(['pdf'])
const AUDIO_PREVIEW_EXTENSIONS = new Set(['aac', 'flac', 'm4a', 'mp3', 'oga', 'ogg', 'opus', 'wav', 'wave'])
const VIDEO_PREVIEW_EXTENSIONS = new Set(['m4v', 'mov', 'mp4', 'ogv', 'webm'])
function extensionFromFilename(filename: string): string | null { function extensionFromFilename(filename: string): string | null {
const lastSegment = filename.split(/[\\/]/u).pop() ?? filename const lastSegment = filename.split(/[\\/]/u).pop() ?? filename
@@ -44,6 +46,8 @@ export function filePreviewKind(entry: Pick<VaultEntry, 'fileKind' | 'filename'
if (!extension) return null if (!extension) return null
if (IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image' if (IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image'
if (PDF_PREVIEW_EXTENSIONS.has(extension)) return 'pdf' if (PDF_PREVIEW_EXTENSIONS.has(extension)) return 'pdf'
if (AUDIO_PREVIEW_EXTENSIONS.has(extension)) return 'audio'
if (VIDEO_PREVIEW_EXTENSIONS.has(extension)) return 'video'
return null return null
} }

View File

@@ -20,6 +20,14 @@ describe('Tauri Content Security Policy', () => {
expect(csp['object-src']).toContain('http://asset.localhost') expect(csp['object-src']).toContain('http://asset.localhost')
}) })
it('allows audio and video media previews from scoped Tauri asset URLs', () => {
const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8'))
const csp = config.app.security.csp as Record<string, string>
expect(csp['media-src']).toContain('asset:')
expect(csp['media-src']).toContain('http://asset.localhost')
})
it('allows bundled tldraw translation JSON fetched from inlined data URLs', () => { it('allows bundled tldraw translation JSON fetched from inlined data URLs', () => {
const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8')) const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8'))
const csp = config.app.security.csp as Record<string, string> const csp = config.app.security.csp as Record<string, string>