diff --git a/src/main/files/binary.js b/src/main/files/binary.js index 2d43995..f388577 100644 --- a/src/main/files/binary.js +++ b/src/main/files/binary.js @@ -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 }; diff --git a/src/preload.js b/src/preload.js index cb338f1..d3afcdb 100644 --- a/src/preload.js +++ b/src/preload.js @@ -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), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 105653e..6d205af 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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?.(); diff --git a/src/renderer/components/editor/EditorPane.tsx b/src/renderer/components/editor/EditorPane.tsx index 605e465..2790bc9 100644 --- a/src/renderer/components/editor/EditorPane.tsx +++ b/src/renderer/components/editor/EditorPane.tsx @@ -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(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) { diff --git a/src/renderer/components/modals/ExportRevealjsDialog.tsx b/src/renderer/components/modals/ExportRevealjsDialog.tsx new file mode 100644 index 0000000..62e4f89 --- /dev/null +++ b/src/renderer/components/modals/ExportRevealjsDialog.tsx @@ -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(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 ( + !o && closeModal()}> + + + Export Reveal.js Slides + {sourcePath} + + +
+ {/* Theme & Transitions */} +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + {/* Toggle Switches */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Template & Metadata */} +
+ + +
+ +
+ setTemplate(e.target.value)} + placeholder="default" + className="flex-1" + /> + +
+
+ +
+
+ + setTitle(e.target.value)} /> +
+
+ + setAuthor(e.target.value)} /> +
+
+ + setDate(e.target.value)} /> +
+
+
+ + {/* Bibliography & CSL */} +
+ + +
+ +
+ setBibliography(e.target.value)} + placeholder="/path/to/citations.bib" + className="flex-1" + /> + +
+
+ +
+ +
+ setCsl(e.target.value)} + placeholder="/path/to/style.csl" + className="flex-1" + /> + +
+
+
+ + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ ); +} diff --git a/src/renderer/components/modals/ModalLayer.tsx b/src/renderer/components/modals/ModalLayer.tsx index 78c0fab..6824d34 100644 --- a/src/renderer/components/modals/ModalLayer.tsx +++ b/src/renderer/components/modals/ModalLayer.tsx @@ -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 ; case 'export-html': return ; + case 'export-revealjs': + return ; case 'export-batch': return ; case 'settings': diff --git a/src/renderer/components/modals/PdfEditorDialog.tsx b/src/renderer/components/modals/PdfEditorDialog.tsx index a1ec9d0..2515448 100644 --- a/src/renderer/components/modals/PdfEditorDialog.tsx +++ b/src/renderer/components/modals/PdfEditorDialog.tsx @@ -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(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 ( +
+
+ {loading && } + +
+
Page {pageState.originalPageNum}
+
+ + + + +
+
+ ); +} + type Operation = | 'merge' | 'split' @@ -225,6 +356,76 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) { const [permissionsPassword, setPermissionsPassword] = useState(''); const [permissions, setPermissions] = useState>({}); + const [pdfDoc, setPdfDoc] = useState(null); + const [pdfPages, setPdfPages] = useState([]); + 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 ( !o && onClose()}> - + 0 || pdfLoading) + ? 'max-w-5xl w-[90vw]' + : 'max-w-2xl' + }`}> PDF Editor Manipulate and transform PDF files @@ -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" > {OPERATIONS.map((op) => ( @@ -492,7 +698,8 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) { ))} -
+
+
@@ -963,7 +1170,65 @@ export function PdfEditorDialog({ onClose, initialFilePath }: Props) { {renderError()}
- + + {['rotate', 'delete', 'reorder'].includes(activeTab) && (pdfPages.length > 0 || pdfLoading) && ( +
+

Page Thumbnails

+ {pdfLoading ? ( +
+ +
+ ) : ( +
+ {pdfPages.map((pageState, index) => ( + { + 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; + }); + }} + /> + ))} +
+ )} +
+ )} +
+ {renderProgress()} diff --git a/src/renderer/components/preview/MarkdownRenderer.tsx b/src/renderer/components/preview/MarkdownRenderer.tsx index b149e68..802982b 100644 --- a/src/renderer/components/preview/MarkdownRenderer.tsx +++ b/src/renderer/components/preview/MarkdownRenderer.tsx @@ -32,7 +32,7 @@ export function MarkdownRenderer({ source }: Props) { }, [source]); return ( -
+
{mermaidCodes.map((code, i) => (
diff --git a/src/renderer/lib/commands/register-menu-commands.ts b/src/renderer/lib/commands/register-menu-commands.ts index ad15d53..5653708 100644 --- a/src/renderer/lib/commands/register-menu-commands.ts +++ b/src/renderer/lib/commands/register-menu-commands.ts @@ -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); } diff --git a/src/renderer/lib/ipc.ts b/src/renderer/lib/ipc.ts index 8545c58..76a1cc7 100644 --- a/src/renderer/lib/ipc.ts +++ b/src/renderer/lib/ipc.ts @@ -94,7 +94,11 @@ export const ipc = { writeBuffer: (args: { path: string; buffer: Uint8Array; - }): Promise> => safeCall('file', 'list', args.path), + }): Promise> => safeCall('file', 'writeBuffer', args.path, args.buffer), + readBuffer: ( + path: string + ): Promise> => + safeCall('file', 'readBuffer', path), gitStage: (args: { rootPath: string; files: string[]; diff --git a/src/renderer/stores/app-store.ts b/src/renderer/stores/app-store.ts index 47b86c9..5a50157 100644 --- a/src/renderer/stores/app-store.ts +++ b/src/renderer/stores/app-store.ts @@ -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' } diff --git a/src/renderer/stores/preview-store.ts b/src/renderer/stores/preview-store.ts index 250c601..d2c1908 100644 --- a/src/renderer/stores/preview-store.ts +++ b/src/renderer/stores/preview-store.ts @@ -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((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((set) => ({ }, DEBOUNCE_MS); }, setScrollRatio: (r) => set({ scrollRatio: r }), + setLargeFileMode: (v) => set({ largeFileMode: v }), + forceRender: (s) => { + if (timer) clearTimeout(timer); + set({ source: s }); + }, })); diff --git a/src/renderer/test/setup.ts b/src/renderer/test/setup.ts index 51af273..c2979bd 100644 --- a/src/renderer/test/setup.ts +++ b/src/renderer/test/setup.ts @@ -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; +} diff --git a/tests/component/modals/AboutDialog.test.tsx b/tests/component/modals/AboutDialog.test.tsx index 2ce5d3a..9cfc629 100644 --- a/tests/component/modals/AboutDialog.test.tsx +++ b/tests/component/modals/AboutDialog.test.tsx @@ -7,10 +7,8 @@ import { useAppStore } from '@/stores/app-store'; describe('AboutDialog', () => { beforeEach(() => { window.electronAPI = { - app: { - getVersion: vi.fn().mockResolvedValue('5.0.0'), - openExternal: vi.fn().mockResolvedValue({ ok: true }), - }, + getAppVersion: vi.fn().mockResolvedValue('5.0.0'), + send: vi.fn().mockResolvedValue({ ok: true }), } as any; // Reset store to about modal so dialog renders useAppStore.setState({ modal: { kind: 'about' } } as any); diff --git a/tests/component/modals/ExportBatchDialog.test.tsx b/tests/component/modals/ExportBatchDialog.test.tsx index 340dd02..919236e 100644 --- a/tests/component/modals/ExportBatchDialog.test.tsx +++ b/tests/component/modals/ExportBatchDialog.test.tsx @@ -7,12 +7,12 @@ describe('ExportBatchDialog', () => { beforeEach(() => { localStorage.clear(); window.electronAPI = { - export: { - batch: vi.fn().mockResolvedValue({ - ok: true, - data: { total: 2, succeeded: 2, failed: 0, results: [] }, - }), - }, + invoke: vi.fn().mockResolvedValue({ + total: 2, + succeeded: 2, + failed: 0, + results: [], + }), } as any; }); @@ -27,11 +27,12 @@ describe('ExportBatchDialog', () => { await userEvent.click(screen.getByRole('combobox', { name: /format/i })); await userEvent.click(screen.getByRole('option', { name: /^pdf$/i })); await userEvent.click(screen.getByRole('button', { name: /^export$/i })); - const call = (window.electronAPI.export.batch as any).mock.calls[0]; - expect(call[0]).toEqual([ + const call = (window.electronAPI.invoke as any).mock.calls[0]; + expect(call[0]).toBe('batch-convert'); + expect(call[1].items).toEqual([ { inputPath: '/a.md', outputPath: expect.any(String) }, { inputPath: '/b.md', outputPath: expect.any(String) }, ]); - expect(call[1].format).toBe('pdf'); + expect(call[1].options.format).toBe('pdf'); }); }); diff --git a/tests/component/modals/HeaderFooterDialog.test.tsx b/tests/component/modals/HeaderFooterDialog.test.tsx index 7b367db..d02b2b8 100644 --- a/tests/component/modals/HeaderFooterDialog.test.tsx +++ b/tests/component/modals/HeaderFooterDialog.test.tsx @@ -18,28 +18,45 @@ const defaultSettings = { logoPath: null, }; -const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings); -const mockSaveSettings = vi.fn().mockResolvedValue(undefined); -const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png'); +let registeredCallback: ((data: any) => void) | null = null; +const mockSend = vi.fn((channel, ...args) => { + if (channel === 'get-header-footer-settings') { + setTimeout(() => { + if (registeredCallback) registeredCallback(defaultSettings); + }, 0); + } +}); + +const mockOn = vi.fn((channel, cb) => { + if (channel === 'header-footer-settings-data') { + registeredCallback = cb as any; + // Trigger immediately with default settings to satisfy initial mount loading + setTimeout(() => { + cb(defaultSettings); + }, 0); + } + return vi.fn(); // cleanup/unsubscribe fn +}); beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); + registeredCallback = null; useSettingsStore.setState(useSettingsStore.getInitialState()); useAppStore.setState({ modal: { kind: null } } as any); (window.electronAPI as any) = { - headerFooter: { - getSettings: mockGetSettings, - saveSettings: mockSaveSettings, - browseLogo: mockBrowseLogo, - }, + send: mockSend, + on: mockOn, + once: vi.fn(), + invoke: vi.fn(() => Promise.resolve(null)), + removeAllListeners: vi.fn(), }; }); describe('HeaderFooterDialog', () => { it('loads settings from electronAPI on mount', async () => { render(); - await waitFor(() => expect(mockGetSettings).toHaveBeenCalled()); + await waitFor(() => expect(mockSend).toHaveBeenCalledWith('get-header-footer-settings')); expect(screen.getByText(/header & footer/i)).toBeInTheDocument(); }); @@ -61,7 +78,7 @@ describe('HeaderFooterDialog', () => { render(); await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument()); await userEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => expect(mockSaveSettings).toHaveBeenCalled()); + await waitFor(() => expect(mockSend).toHaveBeenCalledWith('save-header-footer-settings', expect.any(Object))); }); it('closes modal on Cancel', async () => { @@ -72,7 +89,16 @@ describe('HeaderFooterDialog', () => { }); it('inserts dynamic field token into header input', async () => { - mockGetSettings.mockResolvedValue({ ...defaultSettings, headerEnabled: true }); + // Override registered callback to return enabled header + mockOn.mockImplementationOnce((channel, cb) => { + if (channel === 'header-footer-settings-data') { + setTimeout(() => { + cb({ ...defaultSettings, headerEnabled: true }); + }, 0); + } + return vi.fn(); + }); + render(); await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument()); const tokenButton = screen.getAllByTitle('Page')[0]; diff --git a/tests/component/modals/PdfEditorDialog.test.tsx b/tests/component/modals/PdfEditorDialog.test.tsx index df5a0ca..39b6c21 100644 --- a/tests/component/modals/PdfEditorDialog.test.tsx +++ b/tests/component/modals/PdfEditorDialog.test.tsx @@ -7,6 +7,28 @@ const mockProcessOperation = vi.fn().mockResolvedValue(undefined); const mockPickFile = vi.fn(); const mockPickFolder = vi.fn(); const mockShowSaveDialog = vi.fn(); +const mockReadBuffer = vi.fn(); + +// Mock pdfjs-dist to avoid rendering/worker issues in node test environment +vi.mock('pdfjs-dist', () => { + const mockRenderPromise = Promise.resolve(); + const mockPage = { + getViewport: vi.fn().mockReturnValue({ width: 100, height: 150 }), + render: vi.fn().mockReturnValue({ promise: mockRenderPromise }), + }; + const mockDoc = { + numPages: 3, + getPage: vi.fn().mockResolvedValue(mockPage), + }; + return { + GlobalWorkerOptions: { + workerSrc: '', + }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.resolve(mockDoc), + }), + }; +}); beforeEach(() => { localStorage.clear(); @@ -14,10 +36,12 @@ beforeEach(() => { mockPickFile.mockResolvedValue({ ok: true, data: null }); mockPickFolder.mockResolvedValue({ ok: true, data: null }); mockShowSaveDialog.mockResolvedValue({ ok: true, data: null }); + mockReadBuffer.mockResolvedValue({ ok: true, data: new Uint8Array([1, 2, 3]) }); window.electronAPI = { file: { pickFile: mockPickFile, pickFolder: mockPickFolder, + readBuffer: mockReadBuffer, }, app: { showSaveDialog: mockShowSaveDialog, @@ -294,4 +318,69 @@ describe('PdfEditorDialog', () => { await userEvent.click(screen.getByText('Compress')); expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument(); }); + + it('renders interactive page thumbnails in rotate tab', async () => { + render( {}} 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( {}} 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( {}} 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'); + }); }); diff --git a/tests/component/sidebar/GitStatusPanel.test.tsx b/tests/component/sidebar/GitStatusPanel.test.tsx index 7b5ce98..8de0739 100644 --- a/tests/component/sidebar/GitStatusPanel.test.tsx +++ b/tests/component/sidebar/GitStatusPanel.test.tsx @@ -3,38 +3,20 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { GitStatusPanel } from '@/components/sidebar/GitStatusPanel'; import { useFileStore } from '@/stores/file-store'; - -vi.mock('@/lib/ipc', () => ({ - ipc: { - file: { - gitStatus: vi.fn(), - }, - }, -})); - -vi.mock('@/lib/toast', () => ({ - toast: { - success: vi.fn(), - error: vi.fn(), - info: vi.fn(), - warning: vi.fn(), - promise: vi.fn(), - dismiss: vi.fn(), - }, -})); - import { ipc } from '@/lib/ipc'; const mockGitStage = vi.fn(); const mockGitCommit = vi.fn(); -Object.defineProperty(window, 'electronAPI', { - value: { - gitStage: mockGitStage, - gitCommit: mockGitCommit, +vi.mock('@/lib/ipc', () => ({ + ipc: { + file: { + gitStatus: vi.fn(), + gitStage: (args: any) => mockGitStage(args), + gitCommit: (args: any) => mockGitCommit(args), + }, }, - writable: true, -}); +})); describe('GitStatusPanel', () => { beforeEach(() => { @@ -126,7 +108,7 @@ describe('GitStatusPanel', () => { }); it('stages selected files', async () => { - mockGitStage.mockResolvedValueOnce(undefined); + mockGitStage.mockResolvedValueOnce({ ok: true }); (ipc.file.gitStatus as any).mockResolvedValue({ ok: true, data: [{ filePath: '/project/a.md', status: 'modified' }], @@ -137,11 +119,11 @@ describe('GitStatusPanel', () => { await userEvent.click(checkbox); const stageBtn = screen.getByTestId('git-stage-selected'); await userEvent.click(stageBtn); - expect(mockGitStage).toHaveBeenCalledWith(['/project/a.md']); + expect(mockGitStage).toHaveBeenCalledWith({ rootPath: '/project', files: ['/project/a.md'] }); }); it('commits with message', async () => { - mockGitCommit.mockResolvedValueOnce(undefined); + mockGitCommit.mockResolvedValueOnce({ ok: true }); (ipc.file.gitStatus as any).mockResolvedValue({ ok: true, data: [{ filePath: '/project/a.md', status: 'modified' }], @@ -152,6 +134,6 @@ describe('GitStatusPanel', () => { await userEvent.type(input, 'fix typo'); const commitBtn = screen.getByTestId('git-commit-button'); await userEvent.click(commitBtn); - expect(mockGitCommit).toHaveBeenCalledWith('fix typo'); + expect(mockGitCommit).toHaveBeenCalledWith({ rootPath: '/project', message: 'fix typo' }); }); });