fix: export notes directly to PDF

This commit is contained in:
Tolaria
2026-05-31 14:43:15 +02:00
parent f003b38518
commit 74891e0283
19 changed files with 548 additions and 71 deletions

View File

@@ -199,7 +199,7 @@ Deep links are navigation-only. Opening one can focus Tolaria, switch to a regis
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. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. 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.
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print and invokes Tauri's native webview print command, and `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before the system print/save-to-PDF dialog opens. The PDF reflects the current rendered editor DOM and leaves the vault file unchanged.
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print-only rendering, asks for a `.pdf` filesystem destination only when the native capability reports direct save support, and invokes Tauri's native `WKWebView` PDF export command on macOS. Windows/Linux Tauri builds and browser mode keep print-dialog fallback behavior. `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before export, so frontmatter is ignored and the PDF reflects the current rendered editor DOM while leaving the vault file unchanged.
### Note Content Freshness

View File

@@ -230,7 +230,7 @@ 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 for single-vault lists: 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. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. 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. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. 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. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
- **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. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. 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 filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, 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` plus lazy direct `@shikijs/langs` registrations for missing common grammars. 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. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. 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.
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified.
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu.
- **Right side panels** (200-500px or hidden): Properties and Table of Contents 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. The breadcrumb bar toggles Table of Contents and Properties actions. 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).
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior.
@@ -912,7 +912,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, and the breadcrumb overflow menu, all routed through the same editor export hook. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:

View File

@@ -0,0 +1,31 @@
# ADR-0136: macOS Webview PDF Export
## Status
Accepted
## Context
The first note PDF export implementation reused the native webview print command. On macOS that opens the full printer dialog, which is not the product behavior expected from "Export note as PDF"; users should choose a filesystem destination and get a PDF directly.
Tolaria already renders the exportable note in the live BlockNote DOM and applies print-only CSS so math, Mermaid, images, code blocks, tables, links, and custom blocks follow the same rendering path users see in the editor. Introducing a second Markdown-to-PDF renderer would duplicate that rendering logic and create drift.
## Decision
Use the existing Tauri webview and WebKit's own print operation to save the current webview directly to a chosen PDF path. The renderer remains responsible for export preparation:
- exit raw/diff modes
- apply the PDF export body class
- ask the user for a `.pdf` destination
- invoke the native `export_current_webview_pdf` command
The native command uses direct `objc2`, `objc2-app-kit`, `objc2-foundation`, and `objc2-web-kit` dependencies on macOS only. These crates are already part of Tauri's platform stack; declaring them directly lets Tolaria ask `WKWebView` for a WebKit-aware `NSPrintOperation` without adding a separate PDF rendering engine.
Windows, Linux, and browser mode keep print-dialog fallback behavior because they do not have the macOS WebKit/AppKit direct PDF save path yet. The renderer checks the native capability before opening a filesystem save dialog, so unsupported platforms do not ask for a destination that cannot be used.
## Consequences
- The macOS path opens a save-file dialog, not the printer dialog.
- The exported PDF keeps using the rendered note DOM, so frontmatter stays excluded by the existing rich-editor body extraction.
- The feature depends on macOS WebKit/AppKit behavior for direct PDF output. Other desktop platforms use the existing native print dialog until they get a platform-specific direct PDF path.
- The new direct dependencies must stay target-scoped to macOS so Linux and Windows builds do not compile AppKit crates.

4
src-tauri/Cargo.lock generated
View File

@@ -5440,6 +5440,10 @@ dependencies = [
"gray_matter",
"log",
"notify",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"regex",
"reqwest 0.12.28",
"sentry",

View File

@@ -43,4 +43,10 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking",
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
objc2-app-kit = "0.3.2"
objc2-foundation = "0.3.2"
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
[dev-dependencies]

View File

@@ -7,6 +7,7 @@ mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod pdf_export;
mod runtime;
mod system;
mod vault;
@@ -22,6 +23,7 @@ pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use pdf_export::*;
pub use runtime::*;
pub use system::*;
pub use vault::*;

View File

@@ -0,0 +1,141 @@
#[tauri::command]
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
native::export_current_webview_pdf(window, output_path)
}
#[tauri::command]
pub fn can_export_current_webview_pdf() -> bool {
native::can_export_current_webview_pdf()
}
#[cfg(target_os = "macos")]
mod native {
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use objc2::runtime::ProtocolObject;
use objc2::ClassType;
use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSString, NSURL};
use objc2_web_kit::WKWebView;
const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15);
pub fn can_export_current_webview_pdf() -> bool {
true
}
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
validate_output_path(&output_path)?;
let (sender, receiver) = mpsc::channel();
window
.with_webview(move |webview| {
let result = save_webview_pdf(webview, &output_path);
let _ = sender.send(result);
})
.map_err(|error| format!("Failed to access the current webview: {error}"))?;
receiver
.recv_timeout(PDF_EXPORT_TIMEOUT)
.map_err(|_| "Timed out while exporting the current note as PDF".to_string())?
}
fn validate_output_path(output_path: &str) -> Result<(), String> {
if output_path.trim().is_empty() {
return Err("Missing PDF export path".to_string());
}
let path = Path::new(output_path);
if path.file_name().is_none() {
return Err("PDF export path must include a file name".to_string());
}
Ok(())
}
fn save_webview_pdf(
webview: tauri::webview::PlatformWebview,
output_path: &str,
) -> Result<(), String> {
let output = NSString::from_str(output_path);
let output_url = NSURL::fileURLWithPath(&output);
let print_info = NSPrintInfo::sharedPrintInfo();
let print_settings = unsafe { print_info.dictionary() };
let previous_job_disposition = print_info.jobDisposition();
print_info.setJobDisposition(unsafe { NSPrintSaveJob });
unsafe {
print_settings.setObject_forKey(
output_url.as_super().as_super(),
ProtocolObject::from_ref(NSPrintJobSavingURL),
);
}
let webview: &WKWebView = unsafe { &*webview.inner().cast() };
let window = webview
.window()
.ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?;
let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) };
operation.setShowsPrintPanel(false);
operation.setShowsProgressPanel(false);
operation.setCanSpawnSeparateThread(true);
unsafe {
operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo(
&window,
None,
None,
std::ptr::null_mut(),
);
}
print_info.setJobDisposition(&previous_job_disposition);
unsafe {
print_settings.removeObjectForKey(NSPrintJobSavingURL);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_output_path;
#[test]
fn output_path_requires_a_non_blank_value() {
assert!(validate_output_path("").is_err());
assert!(validate_output_path(" ").is_err());
}
#[test]
fn output_path_requires_a_file_name() {
assert!(validate_output_path("/").is_err());
}
#[test]
fn output_path_accepts_a_pdf_file_path() {
assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok());
}
}
}
#[cfg(not(target_os = "macos"))]
mod native {
pub fn can_export_current_webview_pdf() -> bool {
false
}
pub fn export_current_webview_pdf(
_window: tauri::WebviewWindow,
_output_path: String,
) -> Result<(), String> {
Err("Direct PDF export is currently only supported on macOS".to_string())
}
}

View File

@@ -565,6 +565,8 @@ macro_rules! app_invoke_handler {
commands::reinit_telemetry,
commands::should_use_external_media_preview,
commands::print_current_webview,
commands::can_export_current_webview_pdf,
commands::export_current_webview_pdf,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd,

View File

@@ -111,6 +111,7 @@ import {
vaultPathForEntry,
} from './utils/workspaces'
import { activeGitRepositories } from './utils/gitRepositories'
import { isMarkdownEntry } from './utils/typeDefinitions'
import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState'
import { useGitSetupState } from './hooks/useGitSetupState'
import { AppPreferencesProvider, useAppPreferences } from './hooks/useAppPreferences'
@@ -159,6 +160,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
const aiWorkspaceWindow = false
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState<string | null>(null)
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
const neighborhoodHistoryRef = useRef<SidebarSelection[]>([])
const inboxPeriod: InboxPeriod = 'all'
@@ -1305,6 +1307,31 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
updateFrontmatter: notes.handleUpdateFrontmatter,
setToastMessage,
})
const activeTabEntry = activeTab?.entry ?? null
const activeTabPath = activeTabEntry?.path
const handleSelectNoteForPdfExport = notes.handleSelectNote
const handleExportNotePdfFromList = useCallback((entry: VaultEntry) => {
if (!isMarkdownEntry(entry)) return
if (activeTabPath === entry.path) {
pdfExportRef.current?.('note_list_context_menu')
return
}
setPendingNoteListPdfExportPath(entry.path)
handleSelectNoteForPdfExport(entry)
}, [activeTabPath, handleSelectNoteForPdfExport, pdfExportRef])
useEffect(() => {
if (!pendingNoteListPdfExportPath) return
if (!activeTabEntry || activeTabPath !== pendingNoteListPdfExportPath) return
const frameId = requestAnimationFrame(() => {
if (isMarkdownEntry(activeTabEntry)) pdfExportRef.current?.('note_list_context_menu')
setPendingNoteListPdfExportPath(null)
})
return () => cancelAnimationFrame(frameId)
}, [activeTabEntry, activeTabPath, pendingNoteListPdfExportPath, pdfExportRef])
const {
isStartupLoading,
@@ -1553,7 +1580,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
) : (
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

View File

@@ -559,9 +559,14 @@ describe('AiWorkspace', () => {
await waitFor(() => {
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
})
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'stored-chat', title: 'Quarterly sponsor outreach' }),
])
await waitFor(() => {
expect(onConversationSettingsChange.mock.calls.some(([settings]) => (
settings.some((setting) => (
setting.id === 'stored-chat'
&& setting.title === 'Quarterly sponsor outreach'
))
))).toBe(true)
})
})
it('allows a chat title to be renamed from the sidebar', () => {

View File

@@ -307,6 +307,7 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
}
@media print {
html:has(body.tolaria-note-pdf-exporting),
body.tolaria-note-pdf-exporting {
background: #fff !important;
color: #111 !important;
@@ -321,11 +322,20 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
visibility: hidden !important;
}
body.tolaria-note-pdf-exporting :is(#root, .app-shell, .app, .app__editor, .editor, .editor-scroll-area) {
body.tolaria-note-pdf-exporting :is(.app__sidebar, .app__note-list, .breadcrumb-bar, .editor-memory-probe) {
display: none !important;
}
body.tolaria-note-pdf-exporting .editor > .relative > :not(:has([data-note-pdf-export-root="true"])) {
display: none !important;
}
body.tolaria-note-pdf-exporting :is(#root, .app-shell, .app, .app__editor, .editor, .editor > .relative, .editor-content-width--normal, .editor-content-width--wide, .editor-scroll-area) {
display: block !important;
height: auto !important;
min-height: 0 !important;
overflow: visible !important;
width: auto !important;
}
body.tolaria-note-pdf-exporting [data-note-pdf-export-root="true"],
@@ -334,8 +344,6 @@ body.mac-chrome .app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadc
}
body.tolaria-note-pdf-exporting [data-note-pdf-export-root="true"] {
position: absolute !important;
inset: 0 auto auto 0 !important;
display: block !important;
width: 100% !important;
max-width: none !important;

View File

@@ -9,6 +9,7 @@ describe('NoteList context menu', () => {
const onEnterNeighborhood = vi.fn()
const onBulkArchive = vi.fn()
const onBulkDeletePermanently = vi.fn()
const onExportPdf = vi.fn()
const onToggleFavorite = vi.fn()
const onToggleOrganized = vi.fn()
const onRevealFile = vi.fn()
@@ -19,6 +20,7 @@ describe('NoteList context menu', () => {
onEnterNeighborhood,
onBulkArchive,
onBulkDeletePermanently,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -56,6 +58,10 @@ describe('NoteList context menu', () => {
fireEvent.click(screen.getByText('Copy file path'))
expect(onCopyFilePath).toHaveBeenCalledWith(mockEntries[0].path)
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Export note as PDF'))
expect(onExportPdf).toHaveBeenCalledWith(mockEntries[0])
fireEvent.contextMenu(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Archive this note'))
expect(onBulkArchive).toHaveBeenCalledWith([mockEntries[0].path])

View File

@@ -8,6 +8,7 @@ import {
import type { AppLocale } from '../../lib/i18n'
import { trackEvent } from '../../lib/telemetry'
import type { VaultEntry } from '../../types'
import { isMarkdownEntry } from '../../utils/typeDefinitions'
import { NoteListContextMenuNode } from './NoteListContextMenuView'
export type NoteListContextMenuState = {
@@ -22,6 +23,7 @@ interface NoteListContextMenuParams {
onOpenInNewWindow?: (entry: VaultEntry) => void
onArchivePaths?: (paths: string[]) => void
onDeletePaths?: (paths: string[]) => void
onExportPdf?: (entry: VaultEntry) => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
@@ -34,6 +36,7 @@ function hasNoteListContextActions({
onOpenInNewWindow,
onArchivePaths,
onDeletePaths,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -42,6 +45,7 @@ function hasNoteListContextActions({
return [
onOpenInNewWindow,
onEnterNeighborhood && entry.fileKind !== 'binary',
onExportPdf && isMarkdownEntry(entry),
onArchivePaths && !entry.archived,
onDeletePaths,
onToggleFavorite,
@@ -57,6 +61,7 @@ export function useNoteListContextMenu({
onOpenInNewWindow,
onArchivePaths,
onDeletePaths,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -91,6 +96,7 @@ export function useNoteListContextMenu({
onOpenInNewWindow,
onArchivePaths,
onDeletePaths,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -105,6 +111,7 @@ export function useNoteListContextMenu({
onCopyFilePath,
onDeletePaths,
onEnterNeighborhood,
onExportPdf,
onOpenInNewWindow,
onRevealFile,
onToggleFavorite,
@@ -120,6 +127,7 @@ export function useNoteListContextMenu({
onOpenInNewWindow={onOpenInNewWindow}
onArchivePaths={onArchivePaths}
onDeletePaths={onDeletePaths}
onExportPdf={onExportPdf}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}

View File

@@ -4,6 +4,7 @@ import {
ArrowSquareOut,
CheckCircle,
ClipboardText,
FilePdf,
FolderOpen,
MapTrifold,
Star,
@@ -15,6 +16,7 @@ import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCo
import { translate, type AppLocale } from '../../lib/i18n'
import { trackEvent } from '../../lib/telemetry'
import type { VaultEntry } from '../../types'
import { isMarkdownEntry } from '../../utils/typeDefinitions'
import type { NoteListContextMenuState } from './NoteListContextMenu'
interface NoteListContextMenuItem {
@@ -36,6 +38,7 @@ interface NoteListContextMenuNodeProps {
onOpenInNewWindow?: (entry: VaultEntry) => void
onArchivePaths?: (paths: string[]) => void
onDeletePaths?: (paths: string[]) => void
onExportPdf?: (entry: VaultEntry) => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
@@ -50,6 +53,7 @@ type BuildContextMenuItemsParams = Pick<
| 'onOpenInNewWindow'
| 'onArchivePaths'
| 'onDeletePaths'
| 'onExportPdf'
| 'onToggleFavorite'
| 'onToggleOrganized'
| 'onRevealFile'
@@ -145,6 +149,21 @@ function copyFilePathItem(
}]
}
function exportPdfItem(
entry: VaultEntry,
locale: AppLocale,
onExportPdf: ((entry: VaultEntry) => void) | undefined,
selectAction: SelectContextAction,
) {
if (!onExportPdf || !isMarkdownEntry(entry)) return []
return [{
icon: FilePdf,
label: translate(locale, 'editor.toolbar.exportPdf'),
onSelect: () => selectAction('export_pdf', () => onExportPdf(entry)),
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteExportPdf),
}]
}
function archiveItem(
entry: VaultEntry,
locale: AppLocale,
@@ -187,6 +206,7 @@ function buildContextMenuItems(
...neighborhoodItem(entry, props.locale, props.onEnterNeighborhood, selectAction),
...revealFileItem(entry, props.locale, props.onRevealFile, selectAction),
...copyFilePathItem(entry, props.locale, props.onCopyFilePath, selectAction),
...exportPdfItem(entry, props.locale, props.onExportPdf, selectAction),
...archiveItem(entry, props.locale, props.onArchivePaths, selectAction),
...deleteItem(entry, props.locale, props.onDeletePaths, selectAction),
]
@@ -216,6 +236,7 @@ export function NoteListContextMenuNode({
onOpenInNewWindow,
onArchivePaths,
onDeletePaths,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -236,6 +257,7 @@ export function NoteListContextMenuNode({
onOpenInNewWindow,
onArchivePaths,
onDeletePaths,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,

View File

@@ -340,6 +340,7 @@ interface UseNoteListInteractionStateParams {
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onExportPdf?: (entry: VaultEntry) => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
@@ -368,6 +369,7 @@ function useNoteListInteractionState({
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -384,6 +386,7 @@ function useNoteListInteractionState({
locale,
onEnterNeighborhood,
onOpenInNewWindow,
onExportPdf,
onArchivePaths: onBulkArchive,
onDeletePaths: onBulkDeletePermanently,
onToggleFavorite,
@@ -547,6 +550,7 @@ export interface NoteListProps {
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onExportPdf?: (entry: VaultEntry) => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onRevealFile?: (path: string) => void
@@ -675,6 +679,7 @@ export function useNoteListModel({
onUpdateTypeSort,
updateEntry,
onOpenInNewWindow,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,
@@ -733,6 +738,7 @@ export function useNoteListModel({
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onExportPdf,
onToggleFavorite,
onToggleOrganized,
onRevealFile,

View File

@@ -2,10 +2,12 @@ import { useCallback, useEffect, useState, type MutableRefObject } from 'react'
import { translate, type AppLocale } from '../lib/i18n'
import { trackNotePdfExportFailed } from '../lib/productAnalytics'
import {
notePdfExportFilename,
printActiveNoteAsPdf,
type NotePdfExportSource,
} from '../utils/notePdfExport'
import type { VaultEntry } from '../types'
import { isMarkdownEntry } from '../utils/typeDefinitions'
interface EditorPdfExportTab {
entry: VaultEntry
@@ -22,14 +24,101 @@ interface UseEditorPdfExportParams {
rawMode: boolean
}
function isMarkdownTab(activeTab: EditorPdfExportTab | null): boolean {
return activeTab?.entry.fileKind === 'markdown'
interface PreparePdfExportModeParams {
diffMode: boolean
handleToggleDiffExclusive: () => void | Promise<void>
handleToggleRawExclusive: () => void
rawMode: boolean
setPendingSource: (source: NotePdfExportSource | null) => void
source: NotePdfExportSource
}
interface PdfExportErrorParams {
error: unknown
locale: AppLocale
onToast?: (message: string | null) => void
}
interface PendingPdfExportParams {
activeTab: EditorPdfExportTab | null
diffMode: boolean
locale: AppLocale
onToast?: (message: string | null) => void
pendingSource: NotePdfExportSource | null
rawMode: boolean
setPendingSource: (source: NotePdfExportSource | null) => void
}
function isMarkdownTab(activeTab: EditorPdfExportTab | null): activeTab is EditorPdfExportTab {
return Boolean(activeTab && isMarkdownEntry(activeTab.entry))
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function reportPdfExportError({ error, locale, onToast }: PdfExportErrorParams): void {
onToast?.(translate(locale, 'editor.exportPdf.failed', { error: errorMessage(error) }))
}
async function preparePdfExportMode({
diffMode,
handleToggleDiffExclusive,
handleToggleRawExclusive,
rawMode,
setPendingSource,
source,
}: PreparePdfExportModeParams): Promise<void> {
if (diffMode) await Promise.resolve(handleToggleDiffExclusive())
if (rawMode) handleToggleRawExclusive()
setPendingSource(source)
}
function usePendingPdfExport({
activeTab,
diffMode,
locale,
onToast,
pendingSource,
rawMode,
setPendingSource,
}: PendingPdfExportParams): void {
useEffect(() => {
if (!pendingSource || diffMode || rawMode || !isMarkdownTab(activeTab)) return
let cancelled = false
const defaultFilename = notePdfExportFilename(activeTab.entry.filename)
void printActiveNoteAsPdf({ defaultFilename, source: pendingSource })
.catch((error) => {
if (!cancelled) reportPdfExportError({ error, locale, onToast })
})
.finally(() => {
if (!cancelled) setPendingSource(null)
})
return () => {
cancelled = true
}
}, [activeTab, diffMode, locale, onToast, pendingSource, rawMode, setPendingSource])
}
function useRegisteredPdfExportHandler(
pdfExportRef: MutableRefObject<((source?: NotePdfExportSource) => void) | null> | undefined,
exportNoteAsPdf: (source?: NotePdfExportSource) => void,
): void {
useEffect(() => {
if (!pdfExportRef) return undefined
pdfExportRef.current = exportNoteAsPdf
return () => {
if (pdfExportRef.current === exportNoteAsPdf) {
pdfExportRef.current = null
}
}
}, [exportNoteAsPdf, pdfExportRef])
}
export function useEditorPdfExport({
activeTab,
diffMode,
@@ -44,52 +133,25 @@ export function useEditorPdfExport({
const exportNoteAsPdf = useCallback((source: NotePdfExportSource = 'breadcrumb') => {
if (!isMarkdownTab(activeTab)) {
trackNotePdfExportFailed(source, 'print_unavailable')
trackNotePdfExportFailed(source, 'export_unavailable')
onToast?.(translate(locale, 'editor.exportPdf.unavailable'))
return
}
const prepareExport = async () => {
if (diffMode) await Promise.resolve(handleToggleDiffExclusive())
if (rawMode) handleToggleRawExclusive()
setPendingSource(source)
}
void prepareExport().catch((error) => {
onToast?.(translate(locale, 'editor.exportPdf.failed', { error: errorMessage(error) }))
void preparePdfExportMode({
diffMode,
handleToggleDiffExclusive,
handleToggleRawExclusive,
rawMode,
setPendingSource,
source,
}).catch((error) => {
reportPdfExportError({ error, locale, onToast })
})
}, [activeTab, diffMode, handleToggleDiffExclusive, handleToggleRawExclusive, locale, onToast, rawMode])
useEffect(() => {
if (!pendingSource || diffMode || rawMode || !isMarkdownTab(activeTab)) return
let cancelled = false
void printActiveNoteAsPdf({ source: pendingSource })
.catch((error) => {
if (!cancelled) {
onToast?.(translate(locale, 'editor.exportPdf.failed', { error: errorMessage(error) }))
}
})
.finally(() => {
if (!cancelled) setPendingSource(null)
})
return () => {
cancelled = true
}
}, [activeTab, diffMode, locale, onToast, pendingSource, rawMode])
useEffect(() => {
if (!pdfExportRef) return undefined
pdfExportRef.current = exportNoteAsPdf
return () => {
if (pdfExportRef.current === exportNoteAsPdf) {
pdfExportRef.current = null
}
}
}, [exportNoteAsPdf, pdfExportRef])
usePendingPdfExport({ activeTab, diffMode, locale, onToast, pendingSource, rawMode, setPendingSource })
useRegisteredPdfExportHandler(pdfExportRef, exportNoteAsPdf)
return exportNoteAsPdf
}

View File

@@ -12,8 +12,8 @@ type FilePreviewAction = 'copy_deep_link' | 'copy_path' | 'open_external' | 'rev
type AgentBlockedReason = 'agent_unavailable' | 'missing_vault'
type AiWorkspaceMode = 'docked' | 'side' | 'window'
type AiWorkspaceTitleSource = 'generated' | 'manual'
type NotePdfExportFailureReason = 'print_unavailable' | 'print_error'
type NotePdfExportSource = 'breadcrumb' | 'app_command'
type NotePdfExportFailureReason = 'export_unavailable' | 'export_error'
type NotePdfExportSource = 'breadcrumb' | 'app_command' | 'note_list_context_menu'
const ALL_NOTES_VISIBILITY_CATEGORIES: ReadonlyArray<keyof AllNotesFileVisibility> = [
'pdfs',

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
NOTE_PDF_EXPORT_CLASS,
cleanupNotePdfExportPrintMode,
notePdfExportFilename,
printActiveNoteAsPdf,
} from './notePdfExport'
import {
@@ -39,7 +40,7 @@ afterEach(() => {
})
describe('note PDF export', () => {
it('enables print-only mode before opening the system print dialog', async () => {
it('enables print-only mode before opening the browser print dialog', async () => {
const print = vi.fn()
await printActiveNoteAsPdf({ print, source: 'breadcrumb' })
@@ -49,13 +50,58 @@ describe('note PDF export', () => {
expect(trackNotePdfExportStarted).toHaveBeenCalledWith('breadcrumb')
})
it('uses native webview printing inside Tauri', async () => {
it('saves the current native webview to a chosen PDF path inside Tauri', async () => {
tauriRuntimeMock.isTauri.mockReturnValue(true)
const nativeSavePdf = vi.fn().mockResolvedValue(undefined)
await printActiveNoteAsPdf({
canSaveNativePdf: vi.fn().mockResolvedValue(true),
defaultFilename: 'Project Plan.pdf',
nativeSavePdf,
saveDialog: vi.fn().mockResolvedValue('/tmp/project-plan.pdf'),
source: 'app_command',
})
expect(nativeSavePdf).toHaveBeenCalledWith('/tmp/project-plan.pdf')
expect(trackNotePdfExportStarted).toHaveBeenCalledWith('app_command')
expect(document.body).toHaveClass(NOTE_PDF_EXPORT_CLASS)
window.dispatchEvent(new Event('afterprint'))
expect(document.body).not.toHaveClass(NOTE_PDF_EXPORT_CLASS)
})
it('does nothing when the native save dialog is cancelled', async () => {
tauriRuntimeMock.isTauri.mockReturnValue(true)
const nativeSavePdf = vi.fn()
await printActiveNoteAsPdf({
canSaveNativePdf: vi.fn().mockResolvedValue(true),
nativeSavePdf,
saveDialog: vi.fn().mockResolvedValue(null),
source: 'breadcrumb',
})
expect(nativeSavePdf).not.toHaveBeenCalled()
expect(trackNotePdfExportStarted).not.toHaveBeenCalled()
})
it('falls back to the native print dialog when direct PDF saving is unsupported', async () => {
tauriRuntimeMock.isTauri.mockReturnValue(true)
const nativePrint = vi.fn().mockResolvedValue(undefined)
const nativeSavePdf = vi.fn()
const saveDialog = vi.fn()
await printActiveNoteAsPdf({ nativePrint, source: 'app_command' })
await printActiveNoteAsPdf({
canSaveNativePdf: vi.fn().mockResolvedValue(false),
nativePrint,
nativeSavePdf,
saveDialog,
source: 'app_command',
})
expect(nativePrint).toHaveBeenCalledOnce()
expect(nativeSavePdf).not.toHaveBeenCalled()
expect(saveDialog).not.toHaveBeenCalled()
expect(trackNotePdfExportStarted).toHaveBeenCalledWith('app_command')
})
@@ -76,6 +122,11 @@ describe('note PDF export', () => {
})).rejects.toThrow(error)
expect(document.body).not.toHaveClass(NOTE_PDF_EXPORT_CLASS)
expect(trackNotePdfExportFailed).toHaveBeenCalledWith('app_command', 'print_error')
expect(trackNotePdfExportFailed).toHaveBeenCalledWith('app_command', 'export_error')
})
it('builds safe default PDF filenames from markdown filenames', () => {
expect(notePdfExportFilename('Project Plan.md')).toBe('Project Plan.pdf')
expect(notePdfExportFilename('unsafe:/name.markdown')).toBe('unsafe--name.pdf')
})
})

View File

@@ -7,26 +7,38 @@ import { isTauri } from '../mock-tauri'
export const NOTE_PDF_EXPORT_CLASS = 'tolaria-note-pdf-exporting'
const DEFAULT_CLEANUP_DELAY_MS = 30_000
const PDF_EXTENSION = '.pdf'
const UNSAFE_FILENAME_CHARACTERS = '<>:"/\\|?*'
export type NotePdfExportSource = 'breadcrumb' | 'app_command'
export type NotePdfExportFailureReason = 'print_unavailable' | 'print_error'
export type NotePdfExportSource = 'breadcrumb' | 'app_command' | 'note_list_context_menu'
export type NotePdfExportFailureReason = 'export_unavailable' | 'export_error'
export class NotePdfExportUnavailableError extends Error {
constructor() {
super('The system print dialog is not available in this window.')
super('PDF export is not available in this window.')
this.name = 'NotePdfExportUnavailableError'
}
}
interface NotePdfExportOptions {
canSaveNativePdf?: () => Promise<boolean>
cleanupDelayMs?: number
defaultFilename?: string
documentObject?: Document
nativePrint?: () => Promise<void>
nativeSavePdf?: (outputPath: string) => Promise<void>
print?: () => void | Promise<void>
saveDialog?: (defaultFilename: string) => Promise<string | null>
source: NotePdfExportSource
windowObject?: Window
}
type ResolvedPdfExport = {
cleanupAfterRun: boolean
run: () => void | Promise<void>
}
type PdfExportResolution = ResolvedPdfExport | 'cancelled' | null
function waitForPrintStyles(windowObject: Window): Promise<void> {
return new Promise((resolve) => {
windowObject.requestAnimationFrame(() => {
@@ -63,33 +75,116 @@ function schedulePrintModeCleanup(
function resolvePrintFunction(
windowObject: Window,
{
nativePrint = printCurrentNativeWebview,
print,
}: Pick<NotePdfExportOptions, 'nativePrint' | 'print'>,
): (() => void | Promise<void>) | null {
if (print) return print
if (isTauri()) return nativePrint
}: Pick<NotePdfExportOptions, 'print'>,
): PdfExportResolution {
if (print) return { cleanupAfterRun: false, run: print }
return typeof windowObject.print === 'function'
? () => windowObject.print()
? { cleanupAfterRun: false, run: () => windowObject.print() }
: null
}
function ensurePdfExtension(path: string): string {
return path.toLowerCase().endsWith(PDF_EXTENSION) ? path : `${path}${PDF_EXTENSION}`
}
function stripMarkdownExtension(filename: string): string {
return filename.replace(/\.(md|markdown)$/i, '')
}
function sanitizeFilenameStem(filename: string): string {
return Array.from(filename, (character) => {
const codePoint = character.codePointAt(0) ?? 0
return codePoint < 32 || UNSAFE_FILENAME_CHARACTERS.includes(character) ? '-' : character
}).join('')
}
export function notePdfExportFilename(filename = 'Untitled Note'): string {
const stem = sanitizeFilenameStem(stripMarkdownExtension(filename)).trim()
return `${stem || 'Untitled Note'}${PDF_EXTENSION}`
}
async function openPdfSaveDialog(defaultFilename: string): Promise<string | null> {
const { save } = await import('@tauri-apps/plugin-dialog')
const outputPath = await save({
defaultPath: defaultFilename,
filters: [{ name: 'PDF', extensions: ['pdf'] }],
})
return typeof outputPath === 'string' ? ensurePdfExtension(outputPath) : null
}
async function saveCurrentNativeWebviewPdf(outputPath: string): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('export_current_webview_pdf', { outputPath })
}
async function canSaveCurrentNativeWebviewPdf(): Promise<boolean> {
const { invoke } = await import('@tauri-apps/api/core')
return invoke<boolean>('can_export_current_webview_pdf')
}
async function printCurrentNativeWebview(): Promise<void> {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('print_current_webview')
}
async function resolveNativePdfExport({
canSaveNativePdf = canSaveCurrentNativeWebviewPdf,
defaultFilename = notePdfExportFilename(),
nativePrint = printCurrentNativeWebview,
nativeSavePdf = saveCurrentNativeWebviewPdf,
saveDialog = openPdfSaveDialog,
}: Pick<
NotePdfExportOptions,
'canSaveNativePdf' | 'defaultFilename' | 'nativePrint' | 'nativeSavePdf' | 'saveDialog'
>): Promise<PdfExportResolution> {
if (!await canSaveNativePdf()) {
return { cleanupAfterRun: false, run: nativePrint }
}
const outputPath = await saveDialog(defaultFilename)
if (!outputPath) return 'cancelled'
return {
cleanupAfterRun: false,
run: () => nativeSavePdf(outputPath),
}
}
async function resolvePdfExport(
windowObject: Window,
options: Pick<
NotePdfExportOptions,
'canSaveNativePdf' | 'defaultFilename' | 'nativePrint' | 'nativeSavePdf' | 'print' | 'saveDialog'
>,
): Promise<PdfExportResolution> {
if (isTauri()) return resolveNativePdfExport(options)
return resolvePrintFunction(windowObject, options)
}
export async function printActiveNoteAsPdf({
cleanupDelayMs = DEFAULT_CLEANUP_DELAY_MS,
canSaveNativePdf,
defaultFilename,
documentObject = document,
nativePrint,
nativeSavePdf,
print,
saveDialog,
source,
windowObject = window,
}: NotePdfExportOptions): Promise<void> {
const printDocument = resolvePrintFunction(windowObject, { nativePrint, print })
if (!printDocument) {
trackNotePdfExportFailed(source, 'print_unavailable')
const exportDocument = await resolvePdfExport(windowObject, {
canSaveNativePdf,
defaultFilename,
nativePrint,
nativeSavePdf,
print,
saveDialog,
})
if (exportDocument === 'cancelled') return
if (!exportDocument) {
trackNotePdfExportFailed(source, 'export_unavailable')
throw new NotePdfExportUnavailableError()
}
@@ -99,10 +194,11 @@ export async function printActiveNoteAsPdf({
try {
await waitForPrintStyles(windowObject)
await printDocument()
await exportDocument.run()
if (exportDocument.cleanupAfterRun) cleanup()
} catch (error) {
cleanup()
trackNotePdfExportFailed(source, 'print_error')
trackNotePdfExportFailed(source, 'export_error')
throw error
}
}