mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +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));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('read-buffer', async (_event, { path: filePath }) => {
|
||||
const data = await fs.readFile(filePath);
|
||||
return { ok: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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,7 +1170,65 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
|
||||
{renderError()}
|
||||
</TabsContent>
|
||||
</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()}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user