feat(renderer): ExportPdfDialog (format, margins, ascii tables)

This commit is contained in:
2026-06-05 19:19:18 +05:30
parent 68fcaf9bfc
commit 7d083a024d
2 changed files with 145 additions and 0 deletions
@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
import { ExportDialogFooter } from './ExportDialogFooter';
const MARGIN_MAP = {
normal: { top: 25, right: 25, bottom: 25, left: 25 },
narrow: { top: 15, right: 15, bottom: 15, left: 15 },
wide: { top: 35, right: 35, bottom: 35, left: 35 },
} as const;
export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal);
const { fontSize, pdfFormat, pdfMargins, pdfEmbedFonts, renderTablesAsAscii } = useSettingsStore();
const [format, setFormat] = useState<'letter' | 'a4' | 'legal'>(pdfFormat);
const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins);
const [embed, setEmbed] = useState(pdfEmbedFonts);
const [ascii, setAscii] = useState(renderTablesAsAscii);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const source = useExportSource();
const handleSubmit = async () => {
if (!source) {
setError('No file open.');
return;
}
setSubmitting(true);
setError(null);
const result = await ipc.export.pdf({
inputPath: source.path,
outputPath: source.path.replace(/\.md$/, '.pdf'),
format,
margins: MARGIN_MAP[margins],
embedFonts: embed,
renderTablesAsAscii: ascii,
fontSize,
} as any);
if (!result.data?.ok) {
setError(result.data?.error?.message ?? 'Export failed');
setSubmitting(false);
} else {
closeModal();
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent aria-describedby="pdf-desc">
<DialogHeader>
<DialogTitle>Export to PDF</DialogTitle>
<DialogDescription id="pdf-desc">{sourcePath}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<div>
<Label htmlFor="pdf-format">Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as typeof format)}>
<SelectTrigger id="pdf-format" aria-label="Format"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="letter">Letter</SelectItem>
<SelectItem value="a4">A4</SelectItem>
<SelectItem value="legal">Legal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pdf-margins">Margins</Label>
<Select value={margins} onValueChange={(v) => setMargins(v as typeof margins)}>
<SelectTrigger id="pdf-margins" aria-label="Margins"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="narrow">Narrow</SelectItem>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="wide">Wide</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2">
<Checkbox checked={embed} onCheckedChange={(c) => setEmbed(!!c)} aria-label="Embed fonts" />
Embed fonts
</label>
<label className="flex items-center gap-2">
<Checkbox checked={ascii} onCheckedChange={(c) => setAscii(!!c)} aria-label="ASCII tables" />
Render tables as ASCII
</label>
{error && (
<div role="alert" className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive">
{error}
</div>
)}
</div>
<ExportDialogFooter onCancel={closeModal} onSubmit={handleSubmit} submitting={submitting} submitLabel="Export" />
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ExportPdfDialog } from '@/components/modals/ExportPdfDialog';
import { useFileStore } from '@/stores/file-store';
import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store';
describe('ExportPdfDialog', () => {
beforeEach(() => {
localStorage.clear();
window.electronAPI = {
export: {
pdf: vi.fn().mockResolvedValue({ ok: true, data: { outputPath: '/out.pdf', bytes: 1024, durationMs: 100 } }),
},
} as any;
useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ activeTabId: '/test.md', openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }] } as any);
useEditorStore.setState({ buffers: new Map([['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }]]) } as any);
});
it('renders with default PDF options from settings', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to pdf/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
});
it('toggles ASCII tables and submits merged options', async () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
const call = (window.electronAPI.export.pdf as any).mock.calls[0][0];
expect(call.renderTablesAsAscii).toBe(true);
expect(call.format).toBe('a4');
});
it('renders an error banner when IPC fails', async () => {
(window.electronAPI.export.pdf as any).mockResolvedValueOnce({ ok: false, error: { code: 'E', message: 'Pandoc not found' } });
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument();
});
});