feat: complete master feature parity with interactive PDF editing, Reveal.js export dialog, Large File Mode, and scoped CSS

This commit is contained in:
2026-06-15 01:22:17 +05:30
parent 7eb90d467a
commit 36422a9ab3
18 changed files with 917 additions and 66 deletions
+5
View File
@@ -8,6 +8,11 @@ function register() {
await fs.writeFile(filePath, Buffer.from(buffer));
return { ok: true };
});
ipcMain.handle('read-buffer', async (_event, { path: filePath }) => {
const data = await fs.readFile(filePath);
return { ok: true, data };
});
}
module.exports = { register };
+3
View File
@@ -99,6 +99,7 @@ const ALLOWED_SEND_CHANNELS = [
'read-file',
'write-file',
'write-buffer',
'read-buffer',
'delete-file',
'ensure-directory',
'path-exists',
@@ -356,6 +357,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
rendererReady: () => ipcRenderer.send('renderer-ready'),
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
readBuffer: (filePath) => ipcRenderer.invoke('read-buffer', { path: filePath }),
writeBuffer: (filePath, buffer) => ipcRenderer.invoke('write-buffer', { path: filePath, buffer }),
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
+55
View File
@@ -10,10 +10,65 @@ import { FirstRunWizard } from './components/FirstRunWizard';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck';
import { useSettingsStore } from './stores/settings-store';
import { ipc } from './lib/ipc';
import { toast } from './lib/toast';
function scopeCSS(cssText: string, scopeSelector: string) {
if (!cssText) return '';
return cssText.replace(/([^\r\n,{}]+)(,(?=[^}]*{)|(?=[^{]*{))/g, (match, selector, separator) => {
const trimmed = selector.trim();
if (
!trimmed ||
trimmed.startsWith('@') ||
trimmed.startsWith(':root') ||
trimmed.startsWith('from') ||
trimmed.startsWith('to') ||
/^\d+%$/.test(trimmed)
) {
return match;
}
return scopeSelector + ' ' + trimmed + (separator || '');
});
}
function App() {
useWelcomeTrigger();
useAutoUpdateCheck();
const [printOpen, setPrintOpen] = useState(false);
const customCssPath = useSettingsStore((s) => s.customCssPath);
useEffect(() => {
let active = true;
const styleId = 'custom-preview-style';
async function applyCSS() {
if (!customCssPath) {
const styleTag = document.getElementById(styleId);
if (styleTag) styleTag.remove();
return;
}
const r = await ipc.file.read(customCssPath);
if (!active) return;
if (r.ok && r.data) {
let styleTag = document.getElementById(styleId);
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = styleId;
document.head.appendChild(styleTag);
}
styleTag.textContent = scopeCSS(r.data, '.preview-content');
} else {
toast.error('Failed to load custom CSS file');
}
}
void applyCSS();
return () => {
active = false;
};
}, [customCssPath]);
useEffect(() => {
window.electronAPI?.file?.rendererReady?.();
+29 -2
View File
@@ -1,16 +1,43 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { FindReplaceBar } from './FindReplaceBar';
import { useEditorStore } from '@/stores/editor-store';
import { usePreviewStore } from '@/stores/preview-store';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
export function EditorPane() {
const { buffers, activeId } = useEditorStore();
const buf = activeId ? buffers.get(activeId) : null;
const setPreviewSource = usePreviewStore((s) => s.setSource);
const lastActiveId = useRef<string | null>(null);
useEffect(() => {
if (buf) setPreviewSource(buf.content);
if (!buf) return;
const isNewFile = lastActiveId.current !== buf.id;
lastActiveId.current = buf.id;
const isLarge = buf.content.length > 1024 * 1024;
if (isLarge) {
if (!usePreviewStore.getState().largeFileMode) {
usePreviewStore.setState({ largeFileMode: true });
useAppStore.setState({ previewVisible: false });
toast.warning(
'Large content detected (>1MB). Large File Mode enabled to maintain peak responsiveness. Live preview auto-render is disabled.'
);
}
// Only render on initial load / tab switch, not on edits
if (isNewFile) {
usePreviewStore.getState().forceRender(buf.content);
}
} else {
if (usePreviewStore.getState().largeFileMode) {
usePreviewStore.setState({ largeFileMode: false });
}
setPreviewSource(buf.content);
}
}, [buf?.id, buf?.content, buf, setPreviewSource]);
if (!buf) {
@@ -0,0 +1,275 @@
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 { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store';
import { useExportSource } from '@/hooks/use-export-source';
import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { ExportDialogFooter } from './ExportDialogFooter';
export function ExportRevealjsDialog({ sourcePath }: { sourcePath: string }) {
const closeModal = useAppStore((s) => s.closeModal);
const source = useExportSource();
const [revealTheme, setRevealTheme] = useState('black');
const [revealTransition, setRevealTransition] = useState('slide');
const [revealTransitionSpeed, setRevealTransitionSpeed] = useState('default');
const [revealSlideNumber, setRevealSlideNumber] = useState(false);
const [revealControls, setRevealControls] = useState(true);
const [revealProgress, setRevealProgress] = useState(true);
const [revealHistory, setRevealHistory] = useState(true);
const [revealCenter, setRevealCenter] = useState(true);
const [template, setTemplate] = useState('default');
const [title, setTitle] = useState(source?.title || '');
const [author, setAuthor] = useState('');
const [date, setDate] = useState('');
const [bibliography, setBibliography] = useState('');
const [csl, setCsl] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleBrowseFile = async (setter: (v: string) => void) => {
const result = await ipc.file.pickFile();
if (result.ok && result.data) {
setter(result.data);
}
};
const handleSubmit = async () => {
if (!source) {
setError('No file open.');
return;
}
setSubmitting(true);
setError(null);
try {
const options = {
revealTheme,
revealTransition,
revealTransitionSpeed,
revealSlideNumber,
revealControls,
revealProgress,
revealHistory,
revealCenter,
template: template || undefined,
metadata: {
title: title || undefined,
author: author || undefined,
date: date || undefined,
},
bibliography: bibliography || undefined,
csl: csl || undefined,
};
await window.electronAPI?.export?.withOptions?.('revealjs', options);
toast.success('Slide export process completed');
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${msg}`);
setError(msg);
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>Export Reveal.js Slides</DialogTitle>
<DialogDescription>{sourcePath}</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
{/* Theme & Transitions */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="reveal-theme">Theme</Label>
<Select value={revealTheme} onValueChange={setRevealTheme}>
<SelectTrigger id="reveal-theme">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['black', 'white', 'league', 'beige', 'sky', 'night', 'serif', 'simple', 'solarized', 'blood', 'moon'].map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="reveal-transition">Transition</Label>
<Select value={revealTransition} onValueChange={setRevealTransition}>
<SelectTrigger id="reveal-transition">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['slide', 'none', 'fade', 'convex', 'concave', 'zoom'].map((t) => (
<SelectItem key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="reveal-speed">Transition Speed</Label>
<Select value={revealTransitionSpeed} onValueChange={setRevealTransitionSpeed}>
<SelectTrigger id="reveal-speed">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['default', 'fast', 'slow'].map((s) => (
<SelectItem key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggle Switches */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 py-2">
<div className="flex items-center gap-2">
<Switch checked={revealSlideNumber} onCheckedChange={setRevealSlideNumber} id="reveal-slide-number" />
<Label htmlFor="reveal-slide-number">Slide Numbers</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealControls} onCheckedChange={setRevealControls} id="reveal-controls" />
<Label htmlFor="reveal-controls">Controls</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealProgress} onCheckedChange={setRevealProgress} id="reveal-progress" />
<Label htmlFor="reveal-progress">Progress Bar</Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={revealHistory} onCheckedChange={setRevealHistory} id="reveal-history" />
<Label htmlFor="reveal-history">Slide History</Label>
</div>
<div className="flex items-center gap-2 col-span-2">
<Switch checked={revealCenter} onCheckedChange={setRevealCenter} id="reveal-center" />
<Label htmlFor="reveal-center">Center Content Vertically</Label>
</div>
</div>
{/* Template & Metadata */}
<div className="border-t pt-3 space-y-3">
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Metadata & Template</Label>
<div>
<Label htmlFor="reveal-template">Template File</Label>
<div className="flex gap-2">
<Input
id="reveal-template"
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder="default"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setTemplate)}>
Browse
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="reveal-title">Title</Label>
<Input id="reveal-title" value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div>
<Label htmlFor="reveal-author">Author</Label>
<Input id="reveal-author" value={author} onChange={(e) => setAuthor(e.target.value)} />
</div>
<div>
<Label htmlFor="reveal-date">Date</Label>
<Input id="reveal-date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</div>
</div>
{/* Bibliography & CSL */}
<div className="border-t pt-3 space-y-3">
<Label className="font-semibold text-xs text-muted-foreground uppercase tracking-wider block">Bibliography (Pandoc)</Label>
<div>
<Label htmlFor="reveal-bibliography">Bibliography (.bib, .json)</Label>
<div className="flex gap-2">
<Input
id="reveal-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/citations.bib"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setBibliography)}>
Browse
</Button>
</div>
</div>
<div>
<Label htmlFor="reveal-csl">CSL Style</Label>
<div className="flex gap-2">
<Input
id="reveal-csl"
value={csl}
onChange={(e) => setCsl(e.target.value)}
placeholder="/path/to/style.csl"
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => handleBrowseFile(setCsl)}>
Browse
</Button>
</div>
</div>
</div>
{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>
);
}
@@ -8,6 +8,7 @@ import { ExportBatchDialog } from './ExportBatchDialog';
import { ExportDocxDialog } from './ExportDocxDialog';
import { ExportHtmlDialog } from './ExportHtmlDialog';
import { ExportPdfDialog } from './ExportPdfDialog';
import { ExportRevealjsDialog } from './ExportRevealjsDialog';
import { FindInFilesDialog } from './FindInFilesDialog';
import { HeaderFooterDialog } from './HeaderFooterDialog';
import { SettingsSheet } from './SettingsSheet';
@@ -29,6 +30,8 @@ export function ModalLayer() {
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
case 'export-html':
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
case 'export-revealjs':
return <ExportRevealjsDialog sourcePath={modal.props.sourcePath} />;
case 'export-batch':
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
case 'settings':
@@ -1,4 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
// Set up the worker for pdfjs-dist
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.mjs',
import.meta.url
).toString();
import {
Dialog,
DialogContent,
@@ -38,6 +46,129 @@ import {
X,
} from 'lucide-react';
interface PdfPageState {
originalPageNum: number;
rotation: number;
isDeleted: boolean;
}
function PdfPageThumbnail({
pdfDoc,
pageState,
onRotate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: {
pdfDoc: any;
pageState: PdfPageState;
onRotate: () => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
let active = true;
const renderPage = async () => {
if (!canvasRef.current || !pdfDoc) return;
setLoading(true);
try {
const page = await pdfDoc.getPage(pageState.originalPageNum);
if (!active) return;
const viewport = page.getViewport({ scale: 0.25 });
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport,
}).promise;
} catch (err) {
console.error('Error rendering thumbnail:', err);
} finally {
if (active) setLoading(false);
}
};
renderPage();
return () => {
active = false;
};
}, [pdfDoc, pageState.originalPageNum]);
return (
<div
className={`relative flex flex-col items-center p-2 border rounded bg-card text-card-foreground ${
pageState.isDeleted ? 'opacity-40' : ''
}`}
>
<div className="relative border bg-white flex items-center justify-center overflow-hidden w-24 h-32">
{loading && <Loader2 className="absolute animate-spin size-4 text-primary" />}
<canvas
ref={canvasRef}
style={{ transform: `rotate(${pageState.rotation}deg)` }}
className="max-w-full max-h-full transition-transform duration-200"
/>
</div>
<div className="text-[10px] mt-1 font-semibold text-foreground">Page {pageState.originalPageNum}</div>
<div className="flex gap-1 mt-1.5">
<Button
variant="outline"
size="icon"
className="size-6"
onClick={onRotate}
title="Rotate 90°"
type="button"
>
<RotateCw className="size-3" />
</Button>
<Button
variant="outline"
size="icon"
className={`size-6 ${pageState.isDeleted ? 'bg-destructive/10 text-destructive border-destructive/30' : ''}`}
onClick={onDelete}
title={pageState.isDeleted ? 'Restore' : 'Delete'}
type="button"
>
<Trash2 className="size-3" />
</Button>
<Button
variant="outline"
size="icon"
className="size-6"
onClick={onMoveUp}
disabled={isFirst}
title="Move Up"
type="button"
>
<ArrowUpDown className="size-3 rotate-90" />
</Button>
<Button
variant="outline"
size="icon"
className="size-6"
onClick={onMoveDown}
disabled={isLast}
title="Move Down"
type="button"
>
<ArrowUpDown className="size-3 -rotate-90" />
</Button>
</div>
</div>
);
}
type Operation =
| 'merge'
| 'split'
@@ -225,6 +356,76 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
const [permissionsPassword, setPermissionsPassword] = useState('');
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
const [pdfDoc, setPdfDoc] = useState<any>(null);
const [pdfPages, setPdfPages] = useState<PdfPageState[]>([]);
const [pdfLoading, setPdfLoading] = useState(false);
useEffect(() => {
let active = true;
if (!inputPath || !['rotate', 'delete', 'reorder'].includes(activeTab)) {
setPdfDoc(null);
setPdfPages([]);
return;
}
const loadPdf = async () => {
setPdfLoading(true);
setPdfDoc(null);
setPdfPages([]);
try {
const readBufferFn = window.electronAPI?.file?.readBuffer;
if (!readBufferFn) {
console.warn('readBuffer function is not available on window.electronAPI.file');
return;
}
const res = await readBufferFn(inputPath);
if (!active) return;
if (res && res.ok && res.data) {
const doc = await pdfjsLib.getDocument({ data: res.data }).promise;
if (!active) return;
setPdfDoc(doc);
const count = doc.numPages;
const initialPages = Array.from({ length: count }, (_, i) => ({
originalPageNum: i + 1,
rotation: 0,
isDeleted: false,
}));
setPdfPages(initialPages);
}
} catch (err) {
console.error('Error loading PDF in sidebar:', err);
} finally {
if (active) setPdfLoading(false);
}
};
loadPdf();
return () => {
active = false;
};
}, [inputPath, activeTab]);
useEffect(() => {
if (pdfPages.length === 0) return;
if (activeTab === 'rotate') {
const rotated = pdfPages.filter((p) => p.rotation > 0);
const pagesStr = rotated.map((p) => p.originalPageNum).join(',');
setPages(pagesStr);
if (rotated.length > 0) {
setRotateAngle(String(rotated[rotated.length - 1].rotation % 360));
}
} else if (activeTab === 'delete') {
const deletedStr = pdfPages
.filter((p) => p.isDeleted)
.map((p) => p.originalPageNum)
.join(',');
setPages(deletedStr);
} else if (activeTab === 'reorder') {
const orderStr = pdfPages.map((p) => p.originalPageNum).join(',');
setNewOrder(orderStr);
}
}, [pdfPages, activeTab]);
useEffect(() => {
if (initialFilePath) setInputPath(initialFilePath);
}, [initialFilePath]);
@@ -470,7 +671,11 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col">
<DialogContent className={`flex max-h-[85vh] flex-col transition-all duration-300 ${
['rotate', 'delete', 'reorder'].includes(activeTab) && (pdfPages.length > 0 || pdfLoading)
? 'max-w-5xl w-[90vw]'
: 'max-w-2xl'
}`}>
<DialogHeader>
<DialogTitle>PDF Editor</DialogTitle>
<DialogDescription>Manipulate and transform PDF files</DialogDescription>
@@ -482,6 +687,7 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
setActiveTab(v as Operation);
setError(null);
}}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<TabsList className="flex-wrap">
{OPERATIONS.map((op) => (
@@ -492,7 +698,8 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
))}
</TabsList>
<div className="flex-1 overflow-y-auto">
<div className="flex flex-1 gap-6 overflow-hidden min-h-0 mt-3">
<div className="flex-1 overflow-y-auto pr-1">
<TabsContent value="merge" className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label>PDF files</Label>
@@ -963,6 +1170,64 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
{renderError()}
</TabsContent>
</div>
{['rotate', 'delete', 'reorder'].includes(activeTab) && (pdfPages.length > 0 || pdfLoading) && (
<div className="w-80 border-l pl-4 flex flex-col min-h-0">
<h3 className="font-semibold text-sm mb-2 text-foreground">Page Thumbnails</h3>
{pdfLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<div className="flex-1 overflow-y-auto grid grid-cols-2 gap-2 pr-1 content-start">
{pdfPages.map((pageState, index) => (
<PdfPageThumbnail
key={`${pageState.originalPageNum}-${index}`}
pdfDoc={pdfDoc}
pageState={pageState}
isFirst={index === 0}
isLast={index === pdfPages.length - 1}
onRotate={() => {
setPdfPages((prev) =>
prev.map((p, idx) =>
idx === index ? { ...p, rotation: (p.rotation + 90) % 360 } : p
)
);
}}
onDelete={() => {
setPdfPages((prev) =>
prev.map((p, idx) =>
idx === index ? { ...p, isDeleted: !p.isDeleted } : p
)
);
}}
onMoveUp={() => {
if (index === 0) return;
setPdfPages((prev) => {
const next = [...prev];
const temp = next[index];
next[index] = next[index - 1];
next[index - 1] = temp;
return next;
});
}}
onMoveDown={() => {
if (index === pdfPages.length - 1) return;
setPdfPages((prev) => {
const next = [...prev];
const temp = next[index];
next[index] = next[index + 1];
next[index + 1] = temp;
return next;
});
}}
/>
))}
</div>
)}
</div>
)}
</div>
</Tabs>
{renderProgress()}
@@ -32,7 +32,7 @@ export function MarkdownRenderer({ source }: Props) {
}, [source]);
return (
<div className="prose prose-neutral dark:prose-invert max-w-none p-6" ref={containerRef}>
<div className="prose prose-neutral dark:prose-invert max-w-none p-6 preview-content" ref={containerRef}>
<div dangerouslySetInnerHTML={{ __html: html }} />
{mermaidCodes.map((code, i) => (
<div key={i} className="my-4">
@@ -68,6 +68,23 @@ export function registerMenuCommands(): void {
if (!activeTabId) return;
useAppStore.getState().openModal('export-html', { sourcePath: activeTabId });
},
'file.showExportDialog': (format?: string) => {
const activeTabId = useFileStore.getState().activeTabId;
if (!activeTabId) return;
if (format === 'pdf') {
useAppStore.getState().openModal('export-pdf', { sourcePath: activeTabId });
} else if (format === 'docx') {
useAppStore.getState().openModal('export-docx', { sourcePath: activeTabId });
} else if (format === 'html') {
useAppStore.getState().openModal('export-html', { sourcePath: activeTabId });
} else if (format === 'revealjs') {
useAppStore.getState().openModal('export-revealjs' as any, { sourcePath: activeTabId } as any);
} else if (format === 'word') {
useAppStore.getState().openModal('export-word', { sourcePath: activeTabId });
} else if (format) {
window.electronAPI?.export?.withOptions?.(format, {});
}
},
'file.exportBatch': () => {
const paths = useFileStore.getState().openTabs.map((t) => t.path);
if (paths.length === 0) return;
@@ -423,4 +440,5 @@ export function useBridgeNativeMenu(): void {
useMenuAction('show-batch-converter', 'batch.showConverter', (type) => type as string);
useMenuAction('show-document-compare', 'tools.documentCompare');
useMenuAction('open-header-footer-dialog', 'settings.headerFooter');
useMenuAction('show-export-dialog', 'file.showExportDialog', (format) => format as string);
}
+5 -1
View File
@@ -94,7 +94,11 @@ export const ipc = {
writeBuffer: (args: {
path: string;
buffer: Uint8Array;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'list', args.path),
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'writeBuffer', args.path, args.buffer),
readBuffer: (
path: string
): Promise<IpcResult<{ ok: boolean; data: Uint8Array } | ChannelMissing>> =>
safeCall('file', 'readBuffer', path),
gitStage: (args: {
rootPath: string;
files: string[];
+1
View File
@@ -23,6 +23,7 @@ export type ModalState =
| { kind: 'export-docx'; props: { sourcePath: string } }
| { kind: 'export-html'; props: { sourcePath: string } }
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
| { kind: 'export-revealjs'; props: { sourcePath: string } }
| { kind: 'settings' }
| { kind: 'about' }
| { kind: 'welcome' }
+13
View File
@@ -3,8 +3,11 @@ import { create } from 'zustand';
interface PreviewState {
source: string;
scrollRatio: number;
largeFileMode: boolean;
setSource: (s: string) => void;
setScrollRatio: (r: number) => void;
setLargeFileMode: (v: boolean) => void;
forceRender: (s: string) => void;
}
const DEBOUNCE_MS = 300;
@@ -14,7 +17,12 @@ let pending: string = '';
export const usePreviewStore = create<PreviewState>((set) => ({
source: '',
scrollRatio: 0,
largeFileMode: false,
setSource: (s) => {
// If in large file mode, do not auto-update preview source on user typing
if (usePreviewStore.getState().largeFileMode) {
return;
}
pending = s;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
@@ -22,4 +30,9 @@ export const usePreviewStore = create<PreviewState>((set) => ({
}, DEBOUNCE_MS);
},
setScrollRatio: (r) => set({ scrollRatio: r }),
setLargeFileMode: (v) => set({ largeFileMode: v }),
forceRender: (s) => {
if (timer) clearTimeout(timer);
set({ source: s });
},
}));
+90 -4
View File
@@ -1,5 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
// Mock window.electronAPI for tests
declare global {
interface Window {
@@ -8,10 +10,85 @@ declare global {
}
if (typeof window !== 'undefined') {
window.electronAPI = window.electronAPI || {};
if (!window.electronAPI.invoke) {
window.electronAPI.invoke = async () => null;
}
window.electronAPI = {
send: vi.fn(),
on: vi.fn(() => vi.fn()),
once: vi.fn(),
invoke: vi.fn(() => Promise.resolve(null)),
removeAllListeners: vi.fn(),
getAppVersion: vi.fn(() => Promise.resolve('5.0.1')),
file: {
save: vi.fn(),
saveCurrent: vi.fn(),
setCurrent: vi.fn(),
saveRecent: vi.fn(),
clearRecent: vi.fn(),
rendererReady: vi.fn(),
read: vi.fn(() => Promise.resolve('')),
write: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
ensureDir: vi.fn(() => Promise.resolve()),
exists: vi.fn(() => Promise.resolve(false)),
isDirectory: vi.fn(() => Promise.resolve(false)),
copy: vi.fn(() => Promise.resolve()),
move: vi.fn(() => Promise.resolve()),
list: vi.fn(() => Promise.resolve({ ok: true, data: [] })),
pickFolder: vi.fn(() => Promise.resolve({ ok: true, data: null })),
pickFile: vi.fn(() => Promise.resolve({ ok: true, data: null })),
},
theme: {
get: vi.fn(),
},
print: {
doPrint: vi.fn(),
show: vi.fn(),
},
export: {
withOptions: vi.fn(),
spreadsheet: vi.fn(),
},
batch: {
convert: vi.fn(),
selectFolder: vi.fn(),
},
converter: {
convert: vi.fn(),
convertBatch: vi.fn(),
},
headerFooter: {
getSettings: vi.fn(),
saveSettings: vi.fn(),
browseLogo: vi.fn(),
saveLogo: vi.fn(),
clearLogo: vi.fn(),
},
page: {
getSettings: vi.fn(),
updateSettings: vi.fn(),
setCustomStartPage: vi.fn(),
},
pdf: {
processOperation: vi.fn(),
getPageCount: vi.fn(),
selectFolder: vi.fn(),
},
git: {
status: vi.fn(() => Promise.resolve([])),
stage: vi.fn(),
commit: vi.fn(),
},
updater: {
check: vi.fn(),
install: vi.fn(),
getState: vi.fn(),
onStatus: vi.fn(() => vi.fn()),
},
crash: {
read: vi.fn(),
openDir: vi.fn(),
delete: vi.fn(),
},
};
}
// Mock matchMedia for next-themes (jsdom doesn't have it)
@@ -58,3 +135,12 @@ if (typeof window !== 'undefined' && !Element.prototype.hasPointerCapture) {
if (typeof window !== 'undefined' && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = function () {};
}
// Polyfill DOMMatrix for pdfjs-dist (jsdom doesn't implement it)
if (typeof window !== 'undefined' && !(window as any).DOMMatrix) {
class DOMMatrix {
a = 1; b = 0; c = 0; d = 1; e = 0; f = 0;
}
(window as any).DOMMatrix = DOMMatrix;
(global as any).DOMMatrix = DOMMatrix;
}
+2 -4
View File
@@ -7,10 +7,8 @@ import { useAppStore } from '@/stores/app-store';
describe('AboutDialog', () => {
beforeEach(() => {
window.electronAPI = {
app: {
getVersion: vi.fn().mockResolvedValue('5.0.0'),
openExternal: vi.fn().mockResolvedValue({ ok: true }),
},
getAppVersion: vi.fn().mockResolvedValue('5.0.0'),
send: vi.fn().mockResolvedValue({ ok: true }),
} as any;
// Reset store to about modal so dialog renders
useAppStore.setState({ modal: { kind: 'about' } } as any);
@@ -7,12 +7,12 @@ describe('ExportBatchDialog', () => {
beforeEach(() => {
localStorage.clear();
window.electronAPI = {
export: {
batch: vi.fn().mockResolvedValue({
ok: true,
data: { total: 2, succeeded: 2, failed: 0, results: [] },
invoke: vi.fn().mockResolvedValue({
total: 2,
succeeded: 2,
failed: 0,
results: [],
}),
},
} as any;
});
@@ -27,11 +27,12 @@ describe('ExportBatchDialog', () => {
await userEvent.click(screen.getByRole('combobox', { name: /format/i }));
await userEvent.click(screen.getByRole('option', { name: /^pdf$/i }));
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
const call = (window.electronAPI.export.batch as any).mock.calls[0];
expect(call[0]).toEqual([
const call = (window.electronAPI.invoke as any).mock.calls[0];
expect(call[0]).toBe('batch-convert');
expect(call[1].items).toEqual([
{ inputPath: '/a.md', outputPath: expect.any(String) },
{ inputPath: '/b.md', outputPath: expect.any(String) },
]);
expect(call[1].format).toBe('pdf');
expect(call[1].options.format).toBe('pdf');
});
});
@@ -18,28 +18,45 @@ const defaultSettings = {
logoPath: null,
};
const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings);
const mockSaveSettings = vi.fn().mockResolvedValue(undefined);
const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png');
let registeredCallback: ((data: any) => void) | null = null;
const mockSend = vi.fn((channel, ...args) => {
if (channel === 'get-header-footer-settings') {
setTimeout(() => {
if (registeredCallback) registeredCallback(defaultSettings);
}, 0);
}
});
const mockOn = vi.fn((channel, cb) => {
if (channel === 'header-footer-settings-data') {
registeredCallback = cb as any;
// Trigger immediately with default settings to satisfy initial mount loading
setTimeout(() => {
cb(defaultSettings);
}, 0);
}
return vi.fn(); // cleanup/unsubscribe fn
});
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
registeredCallback = null;
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
headerFooter: {
getSettings: mockGetSettings,
saveSettings: mockSaveSettings,
browseLogo: mockBrowseLogo,
},
send: mockSend,
on: mockOn,
once: vi.fn(),
invoke: vi.fn(() => Promise.resolve(null)),
removeAllListeners: vi.fn(),
};
});
describe('HeaderFooterDialog', () => {
it('loads settings from electronAPI on mount', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(mockGetSettings).toHaveBeenCalled());
await waitFor(() => expect(mockSend).toHaveBeenCalledWith('get-header-footer-settings'));
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
});
@@ -61,7 +78,7 @@ describe('HeaderFooterDialog', () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalled());
await waitFor(() => expect(mockSend).toHaveBeenCalledWith('save-header-footer-settings', expect.any(Object)));
});
it('closes modal on Cancel', async () => {
@@ -72,7 +89,16 @@ describe('HeaderFooterDialog', () => {
});
it('inserts dynamic field token into header input', async () => {
mockGetSettings.mockResolvedValue({ ...defaultSettings, headerEnabled: true });
// Override registered callback to return enabled header
mockOn.mockImplementationOnce((channel, cb) => {
if (channel === 'header-footer-settings-data') {
setTimeout(() => {
cb({ ...defaultSettings, headerEnabled: true });
}, 0);
}
return vi.fn();
});
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const tokenButton = screen.getAllByTitle('Page')[0];
@@ -7,6 +7,28 @@ const mockProcessOperation = vi.fn().mockResolvedValue(undefined);
const mockPickFile = vi.fn();
const mockPickFolder = vi.fn();
const mockShowSaveDialog = vi.fn();
const mockReadBuffer = vi.fn();
// Mock pdfjs-dist to avoid rendering/worker issues in node test environment
vi.mock('pdfjs-dist', () => {
const mockRenderPromise = Promise.resolve();
const mockPage = {
getViewport: vi.fn().mockReturnValue({ width: 100, height: 150 }),
render: vi.fn().mockReturnValue({ promise: mockRenderPromise }),
};
const mockDoc = {
numPages: 3,
getPage: vi.fn().mockResolvedValue(mockPage),
};
return {
GlobalWorkerOptions: {
workerSrc: '',
},
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve(mockDoc),
}),
};
});
beforeEach(() => {
localStorage.clear();
@@ -14,10 +36,12 @@ beforeEach(() => {
mockPickFile.mockResolvedValue({ ok: true, data: null });
mockPickFolder.mockResolvedValue({ ok: true, data: null });
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
mockReadBuffer.mockResolvedValue({ ok: true, data: new Uint8Array([1, 2, 3]) });
window.electronAPI = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
readBuffer: mockReadBuffer,
},
app: {
showSaveDialog: mockShowSaveDialog,
@@ -294,4 +318,69 @@ describe('PdfEditorDialog', () => {
await userEvent.click(screen.getByText('Compress'));
expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument();
});
it('renders interactive page thumbnails in rotate tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Rotate tab
await userEvent.click(screen.getByText('Rotate'));
// Wait for the thumbnails to load and render
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
expect(screen.getByText('Page 2')).toBeInTheDocument();
expect(screen.getByText('Page 3')).toBeInTheDocument();
});
// Check Rotate button on page 1 thumbnail
const rotateBtns = screen.getAllByTitle('Rotate 90°');
expect(rotateBtns).toHaveLength(3);
// Click rotate on page 1
await userEvent.click(rotateBtns[0]);
// Check if the form's "Pages" input is updated to "1"
const pagesInput = screen.getByPlaceholderText('All pages') as HTMLInputElement;
expect(pagesInput.value).toBe('1');
});
it('toggles page deletion status in delete tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Delete tab
await userEvent.click(screen.getByText('Delete'));
// Wait for the thumbnails
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
});
// Click Delete button on page 1
const deleteBtns = screen.getAllByTitle('Delete');
await userEvent.click(deleteBtns[0]);
// Check if the pages to delete input is updated to "1"
const pagesInput = screen.getByPlaceholderText('1,3,5-8') as HTMLInputElement;
expect(pagesInput.value).toBe('1');
});
it('reorders pages in reorder tab', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
// Switch to Reorder tab
await userEvent.click(screen.getByText('Reorder'));
// Wait for the thumbnails
await waitFor(() => {
expect(screen.getByText('Page 1')).toBeInTheDocument();
});
// Move Page 1 Down
const moveDownBtns = screen.getAllByTitle('Move Down');
await userEvent.click(moveDownBtns[0]);
// Check if the new order input is updated
const newOrderInput = screen.getByPlaceholderText('3,1,2,5,4') as HTMLTextAreaElement;
expect(newOrderInput.value).toBe('2,1,3');
});
});
+12 -30
View File
@@ -3,38 +3,20 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
import { useFileStore } from '@/stores/file-store';
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
},
},
}));
vi.mock('@/lib/toast', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
promise: vi.fn(),
dismiss: vi.fn(),
},
}));
import { ipc } from '@/lib/ipc';
const mockGitStage = vi.fn();
const mockGitCommit = vi.fn();
Object.defineProperty(window, 'electronAPI', {
value: {
gitStage: mockGitStage,
gitCommit: mockGitCommit,
vi.mock('@/lib/ipc', () => ({
ipc: {
file: {
gitStatus: vi.fn(),
gitStage: (args: any) => mockGitStage(args),
gitCommit: (args: any) => mockGitCommit(args),
},
writable: true,
});
},
}));
describe('GitStatusPanel', () => {
beforeEach(() => {
@@ -126,7 +108,7 @@ describe('GitStatusPanel', () => {
});
it('stages selected files', async () => {
mockGitStage.mockResolvedValueOnce(undefined);
mockGitStage.mockResolvedValueOnce({ ok: true });
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
@@ -137,11 +119,11 @@ describe('GitStatusPanel', () => {
await userEvent.click(checkbox);
const stageBtn = screen.getByTestId('git-stage-selected');
await userEvent.click(stageBtn);
expect(mockGitStage).toHaveBeenCalledWith(['/project/a.md']);
expect(mockGitStage).toHaveBeenCalledWith({ rootPath: '/project', files: ['/project/a.md'] });
});
it('commits with message', async () => {
mockGitCommit.mockResolvedValueOnce(undefined);
mockGitCommit.mockResolvedValueOnce({ ok: true });
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
@@ -152,6 +134,6 @@ describe('GitStatusPanel', () => {
await userEvent.type(input, 'fix typo');
const commitBtn = screen.getByTestId('git-commit-button');
await userEvent.click(commitBtn);
expect(mockGitCommit).toHaveBeenCalledWith('fix typo');
expect(mockGitCommit).toHaveBeenCalledWith({ rootPath: '/project', message: 'fix typo' });
});
});