mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 10:00:17 +05:30
feat: complete master feature parity with interactive PDF editing, Reveal.js export dialog, Large File Mode, and scoped CSS
This commit is contained in:
@@ -8,6 +8,11 @@ function register() {
|
|||||||
await fs.writeFile(filePath, Buffer.from(buffer));
|
await fs.writeFile(filePath, Buffer.from(buffer));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('read-buffer', async (_event, { path: filePath }) => {
|
||||||
|
const data = await fs.readFile(filePath);
|
||||||
|
return { ok: true, data };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { register };
|
module.exports = { register };
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const ALLOWED_SEND_CHANNELS = [
|
|||||||
'read-file',
|
'read-file',
|
||||||
'write-file',
|
'write-file',
|
||||||
'write-buffer',
|
'write-buffer',
|
||||||
|
'read-buffer',
|
||||||
'delete-file',
|
'delete-file',
|
||||||
'ensure-directory',
|
'ensure-directory',
|
||||||
'path-exists',
|
'path-exists',
|
||||||
@@ -356,6 +357,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
rendererReady: () => ipcRenderer.send('renderer-ready'),
|
rendererReady: () => ipcRenderer.send('renderer-ready'),
|
||||||
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
|
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),
|
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
|
||||||
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
|
||||||
|
|||||||
@@ -10,10 +10,65 @@ import { FirstRunWizard } from './components/FirstRunWizard';
|
|||||||
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
|
||||||
import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck';
|
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() {
|
function App() {
|
||||||
useWelcomeTrigger();
|
useWelcomeTrigger();
|
||||||
useAutoUpdateCheck();
|
useAutoUpdateCheck();
|
||||||
const [printOpen, setPrintOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
window.electronAPI?.file?.rendererReady?.();
|
window.electronAPI?.file?.rendererReady?.();
|
||||||
|
|||||||
@@ -1,16 +1,43 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||||
import { FindReplaceBar } from './FindReplaceBar';
|
import { FindReplaceBar } from './FindReplaceBar';
|
||||||
import { useEditorStore } from '@/stores/editor-store';
|
import { useEditorStore } from '@/stores/editor-store';
|
||||||
import { usePreviewStore } from '@/stores/preview-store';
|
import { usePreviewStore } from '@/stores/preview-store';
|
||||||
|
import { useAppStore } from '@/stores/app-store';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
|
|
||||||
export function EditorPane() {
|
export function EditorPane() {
|
||||||
const { buffers, activeId } = useEditorStore();
|
const { buffers, activeId } = useEditorStore();
|
||||||
const buf = activeId ? buffers.get(activeId) : null;
|
const buf = activeId ? buffers.get(activeId) : null;
|
||||||
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
const setPreviewSource = usePreviewStore((s) => s.setSource);
|
||||||
|
const lastActiveId = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [buf?.id, buf?.content, buf, setPreviewSource]);
|
||||||
|
|
||||||
if (!buf) {
|
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 { ExportDocxDialog } from './ExportDocxDialog';
|
||||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||||
|
import { ExportRevealjsDialog } from './ExportRevealjsDialog';
|
||||||
import { FindInFilesDialog } from './FindInFilesDialog';
|
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||||
import { HeaderFooterDialog } from './HeaderFooterDialog';
|
import { HeaderFooterDialog } from './HeaderFooterDialog';
|
||||||
import { SettingsSheet } from './SettingsSheet';
|
import { SettingsSheet } from './SettingsSheet';
|
||||||
@@ -29,6 +30,8 @@ export function ModalLayer() {
|
|||||||
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
|
return <ExportDocxDialog sourcePath={modal.props.sourcePath} />;
|
||||||
case 'export-html':
|
case 'export-html':
|
||||||
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
|
return <ExportHtmlDialog sourcePath={modal.props.sourcePath} />;
|
||||||
|
case 'export-revealjs':
|
||||||
|
return <ExportRevealjsDialog sourcePath={modal.props.sourcePath} />;
|
||||||
case 'export-batch':
|
case 'export-batch':
|
||||||
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
|
return <ExportBatchDialog sourcePaths={modal.props.sourcePaths} />;
|
||||||
case 'settings':
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -38,6 +46,129 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} 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 =
|
type Operation =
|
||||||
| 'merge'
|
| 'merge'
|
||||||
| 'split'
|
| 'split'
|
||||||
@@ -225,6 +356,76 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
|||||||
const [permissionsPassword, setPermissionsPassword] = useState('');
|
const [permissionsPassword, setPermissionsPassword] = useState('');
|
||||||
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
if (initialFilePath) setInputPath(initialFilePath);
|
if (initialFilePath) setInputPath(initialFilePath);
|
||||||
}, [initialFilePath]);
|
}, [initialFilePath]);
|
||||||
@@ -470,7 +671,11 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>PDF Editor</DialogTitle>
|
<DialogTitle>PDF Editor</DialogTitle>
|
||||||
<DialogDescription>Manipulate and transform PDF files</DialogDescription>
|
<DialogDescription>Manipulate and transform PDF files</DialogDescription>
|
||||||
@@ -482,6 +687,7 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
|||||||
setActiveTab(v as Operation);
|
setActiveTab(v as Operation);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<TabsList className="flex-wrap">
|
<TabsList className="flex-wrap">
|
||||||
{OPERATIONS.map((op) => (
|
{OPERATIONS.map((op) => (
|
||||||
@@ -492,7 +698,8 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
|||||||
))}
|
))}
|
||||||
</TabsList>
|
</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">
|
<TabsContent value="merge" className="space-y-3 pt-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>PDF files</Label>
|
<Label>PDF files</Label>
|
||||||
@@ -963,7 +1170,65 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
|||||||
{renderError()}
|
{renderError()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
|
||||||
|
{['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()}
|
{renderProgress()}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function MarkdownRenderer({ source }: Props) {
|
|||||||
}, [source]);
|
}, [source]);
|
||||||
|
|
||||||
return (
|
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 }} />
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
{mermaidCodes.map((code, i) => (
|
{mermaidCodes.map((code, i) => (
|
||||||
<div key={i} className="my-4">
|
<div key={i} className="my-4">
|
||||||
|
|||||||
@@ -68,6 +68,23 @@ export function registerMenuCommands(): void {
|
|||||||
if (!activeTabId) return;
|
if (!activeTabId) return;
|
||||||
useAppStore.getState().openModal('export-html', { sourcePath: activeTabId });
|
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': () => {
|
'file.exportBatch': () => {
|
||||||
const paths = useFileStore.getState().openTabs.map((t) => t.path);
|
const paths = useFileStore.getState().openTabs.map((t) => t.path);
|
||||||
if (paths.length === 0) return;
|
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-batch-converter', 'batch.showConverter', (type) => type as string);
|
||||||
useMenuAction('show-document-compare', 'tools.documentCompare');
|
useMenuAction('show-document-compare', 'tools.documentCompare');
|
||||||
useMenuAction('open-header-footer-dialog', 'settings.headerFooter');
|
useMenuAction('open-header-footer-dialog', 'settings.headerFooter');
|
||||||
|
useMenuAction('show-export-dialog', 'file.showExportDialog', (format) => format as string);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,11 @@ export const ipc = {
|
|||||||
writeBuffer: (args: {
|
writeBuffer: (args: {
|
||||||
path: string;
|
path: string;
|
||||||
buffer: Uint8Array;
|
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: {
|
gitStage: (args: {
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
files: string[];
|
files: string[];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type ModalState =
|
|||||||
| { kind: 'export-docx'; props: { sourcePath: string } }
|
| { kind: 'export-docx'; props: { sourcePath: string } }
|
||||||
| { kind: 'export-html'; props: { sourcePath: string } }
|
| { kind: 'export-html'; props: { sourcePath: string } }
|
||||||
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
|
| { kind: 'export-batch'; props: { sourcePaths: string[] } }
|
||||||
|
| { kind: 'export-revealjs'; props: { sourcePath: string } }
|
||||||
| { kind: 'settings' }
|
| { kind: 'settings' }
|
||||||
| { kind: 'about' }
|
| { kind: 'about' }
|
||||||
| { kind: 'welcome' }
|
| { kind: 'welcome' }
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { create } from 'zustand';
|
|||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
source: string;
|
source: string;
|
||||||
scrollRatio: number;
|
scrollRatio: number;
|
||||||
|
largeFileMode: boolean;
|
||||||
setSource: (s: string) => void;
|
setSource: (s: string) => void;
|
||||||
setScrollRatio: (r: number) => void;
|
setScrollRatio: (r: number) => void;
|
||||||
|
setLargeFileMode: (v: boolean) => void;
|
||||||
|
forceRender: (s: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEBOUNCE_MS = 300;
|
const DEBOUNCE_MS = 300;
|
||||||
@@ -14,7 +17,12 @@ let pending: string = '';
|
|||||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||||
source: '',
|
source: '',
|
||||||
scrollRatio: 0,
|
scrollRatio: 0,
|
||||||
|
largeFileMode: false,
|
||||||
setSource: (s) => {
|
setSource: (s) => {
|
||||||
|
// If in large file mode, do not auto-update preview source on user typing
|
||||||
|
if (usePreviewStore.getState().largeFileMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
pending = s;
|
pending = s;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
@@ -22,4 +30,9 @@ export const usePreviewStore = create<PreviewState>((set) => ({
|
|||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
},
|
},
|
||||||
setScrollRatio: (r) => set({ scrollRatio: r }),
|
setScrollRatio: (r) => set({ scrollRatio: r }),
|
||||||
|
setLargeFileMode: (v) => set({ largeFileMode: v }),
|
||||||
|
forceRender: (s) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
set({ source: s });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
// Mock window.electronAPI for tests
|
// Mock window.electronAPI for tests
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -8,10 +10,85 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.electronAPI = window.electronAPI || {};
|
window.electronAPI = {
|
||||||
if (!window.electronAPI.invoke) {
|
send: vi.fn(),
|
||||||
window.electronAPI.invoke = async () => null;
|
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)
|
// 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) {
|
if (typeof window !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||||
Element.prototype.scrollIntoView = function () {};
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { useAppStore } from '@/stores/app-store';
|
|||||||
describe('AboutDialog', () => {
|
describe('AboutDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.electronAPI = {
|
window.electronAPI = {
|
||||||
app: {
|
getAppVersion: vi.fn().mockResolvedValue('5.0.0'),
|
||||||
getVersion: vi.fn().mockResolvedValue('5.0.0'),
|
send: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
openExternal: vi.fn().mockResolvedValue({ ok: true }),
|
|
||||||
},
|
|
||||||
} as any;
|
} as any;
|
||||||
// Reset store to about modal so dialog renders
|
// Reset store to about modal so dialog renders
|
||||||
useAppStore.setState({ modal: { kind: 'about' } } as any);
|
useAppStore.setState({ modal: { kind: 'about' } } as any);
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ describe('ExportBatchDialog', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
window.electronAPI = {
|
window.electronAPI = {
|
||||||
export: {
|
invoke: vi.fn().mockResolvedValue({
|
||||||
batch: vi.fn().mockResolvedValue({
|
total: 2,
|
||||||
ok: true,
|
succeeded: 2,
|
||||||
data: { total: 2, succeeded: 2, failed: 0, results: [] },
|
failed: 0,
|
||||||
}),
|
results: [],
|
||||||
},
|
}),
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,11 +27,12 @@ describe('ExportBatchDialog', () => {
|
|||||||
await userEvent.click(screen.getByRole('combobox', { name: /format/i }));
|
await userEvent.click(screen.getByRole('combobox', { name: /format/i }));
|
||||||
await userEvent.click(screen.getByRole('option', { name: /^pdf$/i }));
|
await userEvent.click(screen.getByRole('option', { name: /^pdf$/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||||
const call = (window.electronAPI.export.batch as any).mock.calls[0];
|
const call = (window.electronAPI.invoke as any).mock.calls[0];
|
||||||
expect(call[0]).toEqual([
|
expect(call[0]).toBe('batch-convert');
|
||||||
|
expect(call[1].items).toEqual([
|
||||||
{ inputPath: '/a.md', outputPath: expect.any(String) },
|
{ inputPath: '/a.md', outputPath: expect.any(String) },
|
||||||
{ inputPath: '/b.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,
|
logoPath: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings);
|
let registeredCallback: ((data: any) => void) | null = null;
|
||||||
const mockSaveSettings = vi.fn().mockResolvedValue(undefined);
|
const mockSend = vi.fn((channel, ...args) => {
|
||||||
const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png');
|
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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
registeredCallback = null;
|
||||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||||
useAppStore.setState({ modal: { kind: null } } as any);
|
useAppStore.setState({ modal: { kind: null } } as any);
|
||||||
(window.electronAPI as any) = {
|
(window.electronAPI as any) = {
|
||||||
headerFooter: {
|
send: mockSend,
|
||||||
getSettings: mockGetSettings,
|
on: mockOn,
|
||||||
saveSettings: mockSaveSettings,
|
once: vi.fn(),
|
||||||
browseLogo: mockBrowseLogo,
|
invoke: vi.fn(() => Promise.resolve(null)),
|
||||||
},
|
removeAllListeners: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('HeaderFooterDialog', () => {
|
describe('HeaderFooterDialog', () => {
|
||||||
it('loads settings from electronAPI on mount', async () => {
|
it('loads settings from electronAPI on mount', async () => {
|
||||||
render(<HeaderFooterDialog />);
|
render(<HeaderFooterDialog />);
|
||||||
await waitFor(() => expect(mockGetSettings).toHaveBeenCalled());
|
await waitFor(() => expect(mockSend).toHaveBeenCalledWith('get-header-footer-settings'));
|
||||||
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
|
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +78,7 @@ describe('HeaderFooterDialog', () => {
|
|||||||
render(<HeaderFooterDialog />);
|
render(<HeaderFooterDialog />);
|
||||||
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
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 () => {
|
it('closes modal on Cancel', async () => {
|
||||||
@@ -72,7 +89,16 @@ describe('HeaderFooterDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('inserts dynamic field token into header input', async () => {
|
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 />);
|
render(<HeaderFooterDialog />);
|
||||||
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
|
||||||
const tokenButton = screen.getAllByTitle('Page')[0];
|
const tokenButton = screen.getAllByTitle('Page')[0];
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ const mockProcessOperation = vi.fn().mockResolvedValue(undefined);
|
|||||||
const mockPickFile = vi.fn();
|
const mockPickFile = vi.fn();
|
||||||
const mockPickFolder = vi.fn();
|
const mockPickFolder = vi.fn();
|
||||||
const mockShowSaveDialog = 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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -14,10 +36,12 @@ beforeEach(() => {
|
|||||||
mockPickFile.mockResolvedValue({ ok: true, data: null });
|
mockPickFile.mockResolvedValue({ ok: true, data: null });
|
||||||
mockPickFolder.mockResolvedValue({ ok: true, data: null });
|
mockPickFolder.mockResolvedValue({ ok: true, data: null });
|
||||||
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
|
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
|
||||||
|
mockReadBuffer.mockResolvedValue({ ok: true, data: new Uint8Array([1, 2, 3]) });
|
||||||
window.electronAPI = {
|
window.electronAPI = {
|
||||||
file: {
|
file: {
|
||||||
pickFile: mockPickFile,
|
pickFile: mockPickFile,
|
||||||
pickFolder: mockPickFolder,
|
pickFolder: mockPickFolder,
|
||||||
|
readBuffer: mockReadBuffer,
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
showSaveDialog: mockShowSaveDialog,
|
showSaveDialog: mockShowSaveDialog,
|
||||||
@@ -294,4 +318,69 @@ describe('PdfEditorDialog', () => {
|
|||||||
await userEvent.click(screen.getByText('Compress'));
|
await userEvent.click(screen.getByText('Compress'));
|
||||||
expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,38 +3,20 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
|
import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel';
|
||||||
import { useFileStore } from '@/stores/file-store';
|
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';
|
import { ipc } from '@/lib/ipc';
|
||||||
|
|
||||||
const mockGitStage = vi.fn();
|
const mockGitStage = vi.fn();
|
||||||
const mockGitCommit = vi.fn();
|
const mockGitCommit = vi.fn();
|
||||||
|
|
||||||
Object.defineProperty(window, 'electronAPI', {
|
vi.mock('@/lib/ipc', () => ({
|
||||||
value: {
|
ipc: {
|
||||||
gitStage: mockGitStage,
|
file: {
|
||||||
gitCommit: mockGitCommit,
|
gitStatus: vi.fn(),
|
||||||
|
gitStage: (args: any) => mockGitStage(args),
|
||||||
|
gitCommit: (args: any) => mockGitCommit(args),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
writable: true,
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
describe('GitStatusPanel', () => {
|
describe('GitStatusPanel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -126,7 +108,7 @@ describe('GitStatusPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stages selected files', async () => {
|
it('stages selected files', async () => {
|
||||||
mockGitStage.mockResolvedValueOnce(undefined);
|
mockGitStage.mockResolvedValueOnce({ ok: true });
|
||||||
(ipc.file.gitStatus as any).mockResolvedValue({
|
(ipc.file.gitStatus as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
||||||
@@ -137,11 +119,11 @@ describe('GitStatusPanel', () => {
|
|||||||
await userEvent.click(checkbox);
|
await userEvent.click(checkbox);
|
||||||
const stageBtn = screen.getByTestId('git-stage-selected');
|
const stageBtn = screen.getByTestId('git-stage-selected');
|
||||||
await userEvent.click(stageBtn);
|
await userEvent.click(stageBtn);
|
||||||
expect(mockGitStage).toHaveBeenCalledWith(['/project/a.md']);
|
expect(mockGitStage).toHaveBeenCalledWith({ rootPath: '/project', files: ['/project/a.md'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('commits with message', async () => {
|
it('commits with message', async () => {
|
||||||
mockGitCommit.mockResolvedValueOnce(undefined);
|
mockGitCommit.mockResolvedValueOnce({ ok: true });
|
||||||
(ipc.file.gitStatus as any).mockResolvedValue({
|
(ipc.file.gitStatus as any).mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
data: [{ filePath: '/project/a.md', status: 'modified' }],
|
||||||
@@ -152,6 +134,6 @@ describe('GitStatusPanel', () => {
|
|||||||
await userEvent.type(input, 'fix typo');
|
await userEvent.type(input, 'fix typo');
|
||||||
const commitBtn = screen.getByTestId('git-commit-button');
|
const commitBtn = screen.getByTestId('git-commit-button');
|
||||||
await userEvent.click(commitBtn);
|
await userEvent.click(commitBtn);
|
||||||
expect(mockGitCommit).toHaveBeenCalledWith('fix typo');
|
expect(mockGitCommit).toHaveBeenCalledWith({ rootPath: '/project', message: 'fix typo' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user