feat: enhance WelcomeDialog, export dialogs, GitStatusPanel, add CommandPalette, FindReplaceBar, and new converter dialogs

- WelcomeDialog: add quick start, feature showcase, keyboard shortcuts, recent files, version display
- ExportDocxDialog/ExportHtmlDialog/ExportPdfDialog: add header/footer, paper size, margin controls
- GitStatusPanel: full staging/unstaging, discard, commit workflow
- CommandPalette: fuzzy command search with keyboard navigation
- FindReplaceBar: find/replace with regex, case, whole-word options
- New dialogs: BatchMediaConverterDialog, HeaderFooterDialog, PdfEditorDialog, UniversalConverterDialog
- Tests: comprehensive coverage for all new/updated components
This commit is contained in:
2026-06-13 19:34:42 +05:30
parent 6b564a4569
commit f2398e6e1a
52 changed files with 4309 additions and 250 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ module.exports = [
}, },
rules: { rules: {
// Error prevention // Error prevention
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'no-undef': 'error', 'no-undef': 'error',
'no-console': 'off', // Allow console for Electron apps 'no-console': 'off', // Allow console for Electron apps
+1 -1
View File
@@ -8,7 +8,7 @@ async function getStatus(dir) {
try { try {
const git = getGitInstance(dir); const git = getGitInstance(dir);
return await git.status(); return await git.status();
} catch (err) { } catch (_err) {
return { error: 'Not a git repository' }; return { error: 'Not a git repository' };
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// src/main/files/index.js // src/main/files/index.js
// File ops facade — registers all file-related IPC handlers // File ops facade — registers all file-related IPC handlers
const { ipcMain, dialog, shell } = require('electron'); const { ipcMain, dialog } = require('electron');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { register: registerGit } = require('./git'); const { register: registerGit } = require('./git');
+33 -28
View File
@@ -1,4 +1,4 @@
const { app, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron'); const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron');
const { autoUpdater } = require('electron-updater'); const { autoUpdater } = require('electron-updater');
const { UpdaterService } = require('./updater/updater-service'); const { UpdaterService } = require('./updater/updater-service');
const { CrashWriter } = require('./updater/crash-writer'); const { CrashWriter } = require('./updater/crash-writer');
@@ -7,16 +7,10 @@ const updaterHandlers = require('./ipc/updater-handlers');
const crashHandlers = require('./ipc/crash-handlers'); const crashHandlers = require('./ipc/crash-handlers');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { exec, execFile, spawn } = require('child_process'); const { execFile } = require('child_process');
const WordTemplateExporter = require('./word-template'); const WordTemplateExporter = require('./word-template');
const PDFOperations = require('./PDFOperations'); const PDFOperations = require('./PDFOperations');
const GitOperations = require('./GitOperations'); const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths');
const {
getAllowedDirectories,
validatePath,
resolveWritablePath,
isPathAccessible,
} = require('./utils/paths');
const fileOps = require('./files'); const fileOps = require('./files');
const menu = require('./menu'); const menu = require('./menu');
const { createMainWindow } = require('./window'); const { createMainWindow } = require('./window');
@@ -57,13 +51,13 @@ function getFFmpegPath() {
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep); ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
} }
if (fs.existsSync(ffmpegPath)) return ffmpegPath; if (fs.existsSync(ffmpegPath)) return ffmpegPath;
} catch (_) { } catch (_ffmpegErr) {
/* ffmpeg-static not available */ /* ffmpeg-static not available */
} }
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg'; return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
} }
// Check if Pandoc is available // eslint-disable-next-line no-unused-vars
function checkPandocAvailable() { function checkPandocAvailable() {
return new Promise((resolve) => { return new Promise((resolve) => {
const pandocPath = getPandocPath(); const pandocPath = getPandocPath();
@@ -80,6 +74,7 @@ function checkPandocAvailable() {
* @param {object} options - Options for execFile * @param {object} options - Options for execFile
* @returns {Promise<{stdout: string, stderr: string}>} * @returns {Promise<{stdout: string, stderr: string}>}
*/ */
// eslint-disable-next-line no-unused-vars
function safeExecFile(command, args, options = {}) { function safeExecFile(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile( execFile(
@@ -144,6 +139,7 @@ function convertDataToMarkdown(content, format) {
* @param {string[]} args - Pandoc arguments array * @param {string[]} args - Pandoc arguments array
* @param {Function} callback - Callback function (error, stdout, stderr) * @param {Function} callback - Callback function (error, stdout, stderr)
*/ */
// eslint-disable-next-line no-unused-vars
function runPandoc(args, callback) { function runPandoc(args, callback) {
const pandocPath = getPandocPath(); const pandocPath = getPandocPath();
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback); execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
@@ -373,7 +369,7 @@ function checkPandocAvailability() {
return; return;
} }
execFile('pandoc', ['--version'], (error, stdout, stderr) => { execFile('pandoc', ['--version'], (error, _stdout, _stderr) => {
pandocAvailable = !error; pandocAvailable = !error;
resolve(pandocAvailable); resolve(pandocAvailable);
}); });
@@ -597,7 +593,7 @@ function showDependenciesDialog() {
depsWindow.setMenuBarVisibility(false); depsWindow.setMenuBarVisibility(false);
} }
// Open PDF File for viewing/editing // eslint-disable-next-line no-unused-vars
function openPDFFile() { function openPDFFile() {
const files = dialog.showOpenDialogSync(mainWindow, { const files = dialog.showOpenDialogSync(mainWindow, {
properties: ['openFile'], properties: ['openFile'],
@@ -1198,7 +1194,7 @@ async function exportPDFViaWordTemplate() {
const outputDir = path.dirname(result.filePath); const outputDir = path.dirname(result.filePath);
const sofficeArgs = ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, tempDocxPath]; const sofficeArgs = ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, tempDocxPath];
execFile(soffice, sofficeArgs, (error, stdout, stderr) => { execFile(soffice, sofficeArgs, (error, _stdout, _stderr) => {
// Clean up temporary DOCX file // Clean up temporary DOCX file
try { try {
fs.unlinkSync(tempDocxPath); fs.unlinkSync(tempDocxPath);
@@ -1290,7 +1286,7 @@ function checkConverterAvailable(tool) {
} }
// Handle universal file conversion // Handle universal file conversion
ipcMain.on('universal-convert', async (event, { tool, fromFormat, toFormat, filePath }) => { ipcMain.on('universal-convert', async (_event, { tool, _fromFormat, toFormat, filePath }) => {
if (!conversionLimiter()) { if (!conversionLimiter()) {
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
return; return;
@@ -1378,7 +1374,15 @@ ipcMain.on(
'universal-convert-batch', 'universal-convert-batch',
async ( async (
event, event,
{ tool, fromFormat, toFormat, inputFolder, outputFolder, includeSubfolders, advancedOptions } {
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
includeSubfolders,
advancedOptions: _advancedOptions,
}
) => { ) => {
if (!conversionLimiter()) { if (!conversionLimiter()) {
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...'); mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
@@ -1776,7 +1780,7 @@ function performExportWithOptions(format, options) {
// Clean up intermediate EPUB // Clean up intermediate EPUB
try { try {
fs.unlinkSync(epubFile); fs.unlinkSync(epubFile);
} catch (e) { } catch (_e) {
/* ignore */ /* ignore */
} }
showExportSuccess(outputFile); showExportSuccess(outputFile);
@@ -1797,7 +1801,7 @@ function performExportWithOptions(format, options) {
}); });
} }
function tryPdfFallback(inputFile, outputFile, engines, index, options, lastError) { function tryPdfFallback(inputFile, outputFile, engines, index, options, _lastError) {
if (index >= engines.length) { if (index >= engines.length) {
// All Pandoc PDF engines failed, fallback to Electron's built-in PDF export // All Pandoc PDF engines failed, fallback to Electron's built-in PDF export
console.log('All Pandoc PDF engines failed, falling back to Electron PDF export'); console.log('All Pandoc PDF engines failed, falling back to Electron PDF export');
@@ -1966,13 +1970,14 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
} }
// Helper function to export PDF with pandoc (with fallbacks) - uses runPandocCmd for safety // Helper function to export PDF with pandoc (with fallbacks) - uses runPandocCmd for safety
// eslint-disable-next-line no-unused-vars
function exportWithPandocPDF(pandocCmd, outputFile) { function exportWithPandocPDF(pandocCmd, outputFile) {
runPandocCmd(pandocCmd, (error, stdout, stderr) => { runPandocCmd(pandocCmd, (error, _stdout, _stderr) => {
if (error) { if (error) {
console.log('XeLaTeX failed, trying PDFLaTeX...'); console.log('XeLaTeX failed, trying PDFLaTeX...');
// Fallback to pdflatex // Fallback to pdflatex
const fallbackCmd = pandocCmd.replace('--pdf-engine=xelatex', '--pdf-engine=pdflatex'); const fallbackCmd = pandocCmd.replace('--pdf-engine=xelatex', '--pdf-engine=pdflatex');
runPandocCmd(fallbackCmd, (fallbackError, fallbackStdout, fallbackStderr) => { runPandocCmd(fallbackCmd, (fallbackError, _fallbackStdout, _fallbackStderr) => {
if (fallbackError) { if (fallbackError) {
console.log('PDFLaTeX failed, trying Electron PDF...'); console.log('PDFLaTeX failed, trying Electron PDF...');
// Final fallback to Electron PDF // Final fallback to Electron PDF
@@ -2337,7 +2342,7 @@ function importDocument() {
// Convert to markdown using pandoc (using runPandocCmd for safety) // Convert to markdown using pandoc (using runPandocCmd for safety)
const pandocCmd = `${getPandocPath()} "${inputFile}" -t markdown ${additionalOptions} -o "${outputFile}"`; const pandocCmd = `${getPandocPath()} "${inputFile}" -t markdown ${additionalOptions} -o "${outputFile}"`;
runPandocCmd(pandocCmd, (error, stdout, stderr) => { runPandocCmd(pandocCmd, (error, _stdout, _stderr) => {
if (error) { if (error) {
dialog.showErrorBox( dialog.showErrorBox(
'Import Error', 'Import Error',
@@ -2457,7 +2462,7 @@ ipcMain.on('do-print-with-options', (event, options) => {
}); });
// Handle renderer ready for file association // Handle renderer ready for file association
ipcMain.on('renderer-ready', (event) => { ipcMain.on('renderer-ready', (_event) => {
console.log('[MAIN] renderer-ready received, rendererReady was:', rendererReady); console.log('[MAIN] renderer-ready received, rendererReady was:', rendererReady);
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.executeJavaScript( mainWindow.webContents.executeJavaScript(
@@ -2675,7 +2680,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
const inputFile = markdownFiles[index]; const inputFile = markdownFiles[index];
const relativePath = path.relative(inputFolder, inputFile); const relativePath = path.relative(inputFolder, inputFile);
const baseName = path.basename(relativePath, path.extname(relativePath)); const _baseName = path.basename(relativePath, path.extname(relativePath));
let outputExtension = format; let outputExtension = format;
if (format === 'docx-enhanced') outputExtension = 'docx'; if (format === 'docx-enhanced') outputExtension = 'docx';
if (format === 'pdf-enhanced') outputExtension = 'pdf'; if (format === 'pdf-enhanced') outputExtension = 'pdf';
@@ -2709,7 +2714,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
// Process next file // Process next file
processNextFile(index + 1); processNextFile(index + 1);
} catch (error) { } catch (_error) {
// Update progress with error // Update progress with error
mainWindow.webContents.send('batch-progress', { mainWindow.webContents.send('batch-progress', {
completed: index + 1, completed: index + 1,
@@ -2750,7 +2755,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
tempDocxPath, tempDocxPath,
]; ];
execFile(soffice, sofficeArgs, (error, stdout, stderr) => { execFile(soffice, sofficeArgs, (error, _stdout, _stderr) => {
// Clean up temporary DOCX file // Clean up temporary DOCX file
try { try {
fs.unlinkSync(tempDocxPath); fs.unlinkSync(tempDocxPath);
@@ -2795,7 +2800,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
// Process next file // Process next file
processNextFile(index + 1); processNextFile(index + 1);
}); });
} catch (error) { } catch (_error) {
// Update progress with error // Update progress with error
mainWindow.webContents.send('batch-progress', { mainWindow.webContents.send('batch-progress', {
completed: index + 1, completed: index + 1,
@@ -2904,7 +2909,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
} }
// Execute conversion (using runPandocCmd for safety) // Execute conversion (using runPandocCmd for safety)
runPandocCmd(pandocCmd, async (error, stdout, stderr) => { runPandocCmd(pandocCmd, async (error, _stdout, _stderr) => {
if (!error) { if (!error) {
// Add headers/footers to DOCX if enabled // Add headers/footers to DOCX if enabled
if (format === 'docx' && headerFooterSettings.enabled) { if (format === 'docx' && headerFooterSettings.enabled) {
@@ -3530,7 +3535,7 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
} }
}); });
ipcMain.handle('select-custom-css', async (event) => { ipcMain.handle('select-custom-css', async (_event) => {
const result = dialog.showOpenDialogSync(mainWindow, { const result = dialog.showOpenDialogSync(mainWindow, {
title: 'Select Custom Preview CSS', title: 'Select Custom Preview CSS',
properties: ['openFile'], properties: ['openFile'],
+1 -1
View File
@@ -1,6 +1,6 @@
const { ipcMain, shell } = require('electron'); const { ipcMain, shell } = require('electron');
function register({ crash, getMainWindow }) { function register({ crash, getMainWindow: _getMainWindow }) {
ipcMain.handle('crash:read', () => { ipcMain.handle('crash:read', () => {
return crash.list(); return crash.list();
}); });
+5 -5
View File
@@ -1,7 +1,7 @@
// src/main/menu/items.js // src/main/menu/items.js
// Individual menu items — pure functions that take (mainWindow) and return menu item arrays // Individual menu items — pure functions that take (mainWindow) and return menu item arrays
const { app, dialog, shell } = require('electron'); const { app, shell } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -32,7 +32,7 @@ function buildRecentFilesMenu(mainWindow) {
} }
); );
return items; return items;
} catch (e) { } catch (_e) {
return [{ label: 'No Recent Files', enabled: false }]; return [{ label: 'No Recent Files', enabled: false }];
} }
} }
@@ -628,7 +628,7 @@ function batchItems(mainWindow) {
]; ];
} }
function convertItems(mainWindow) { function convertItems(_mainWindow) {
return [ return [
{ {
label: 'Universal File Converter...', label: 'Universal File Converter...',
@@ -641,7 +641,7 @@ function convertItems(mainWindow) {
]; ];
} }
function pdfEditorItems(mainWindow) { function pdfEditorItems(_mainWindow) {
return [ return [
{ {
label: 'Open PDF File...', label: 'Open PDF File...',
@@ -754,7 +754,7 @@ function toolsItems(mainWindow) {
]; ];
} }
function helpItems(mainWindow) { function helpItems(_mainWindow) {
return [ return [
{ {
label: 'About MarkdownConverter', label: 'About MarkdownConverter',
+1 -1
View File
@@ -31,7 +31,7 @@ class CrashWriter {
const oldest = files.shift(); const oldest = files.shift();
try { try {
fs.unlinkSync(path.join(this.dir, oldest)); fs.unlinkSync(path.join(this.dir, oldest));
} catch (_) { } catch (_unlinkErr) {
/* ignore */ /* ignore */
} }
} }
+2 -2
View File
@@ -29,7 +29,7 @@ function validatePath(filePath) {
let resolved; let resolved;
try { try {
resolved = path.resolve(filePath); resolved = path.resolve(filePath);
} catch (err) { } catch (_err) {
return { valid: false, resolved: '', error: 'Invalid path format' }; return { valid: false, resolved: '', error: 'Invalid path format' };
} }
@@ -63,7 +63,7 @@ function resolveWritablePath(filePath) {
let resolved; let resolved;
try { try {
resolved = path.normalize(path.resolve(filePath)); resolved = path.normalize(path.resolve(filePath));
} catch (err) { } catch (_err2) {
return { valid: false, resolved: '', error: 'Invalid path format' }; return { valid: false, resolved: '', error: 'Invalid path format' };
} }
+2 -3
View File
@@ -7,7 +7,6 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const PizZip = require('pizzip'); const PizZip = require('pizzip');
const Docx = require('docx4js').default;
class WordTemplateExporter { class WordTemplateExporter {
constructor(templatePath, startPage = 3, pageSettings = null) { constructor(templatePath, startPage = 3, pageSettings = null) {
@@ -432,7 +431,7 @@ class WordTemplateExporter {
rowCells.push(''); rowCells.push('');
} }
rowCells.forEach((cellText, colIndex) => { rowCells.forEach((cellText, _colIndex) => {
tableXml += '<w:tc>'; tableXml += '<w:tc>';
tableXml += '<w:tcPr>'; tableXml += '<w:tcPr>';
@@ -715,7 +714,7 @@ class WordTemplateExporter {
*/ */
parseInlineFormatting(text) { parseInlineFormatting(text) {
let xml = ''; let xml = '';
const pos = 0; const _pos = 0;
// Patterns for inline formatting // Patterns for inline formatting
const patterns = [ const patterns = [
@@ -80,7 +80,7 @@ function renderIssues(container, issues) {
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'ws-issue-actions'; actions.className = 'ws-issue-actions';
for (const [action, label] of [ for (const [_action, label] of [
['accept', 'Accept'], ['accept', 'Accept'],
['dismiss', 'Dismiss'], ['dismiss', 'Dismiss'],
]) { ]) {
+1 -1
View File
@@ -14,7 +14,7 @@ class SettingsStore {
this.backend.set(key, value); this.backend.set(key, value);
} }
onChanged(key, callback) { onChanged(_key, _callback) {
// Deferred: plugins read settings on init/activate for MVP. // Deferred: plugins read settings on init/activate for MVP.
// Full change notification requires IPC watcher in main process. // Full change notification requires IPC watcher in main process.
} }
+1
View File
@@ -134,6 +134,7 @@ const ALLOWED_SEND_CHANNELS = [
'app:quit', 'app:quit',
'app:open-external', 'app:open-external',
'app:show-save-dialog', 'app:show-save-dialog',
'get-app-version',
// Updater // Updater
'updater:check', 'updater:check',
+2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
import { ModalLayer } from './components/modals/ModalLayer'; import { ModalLayer } from './components/modals/ModalLayer';
import { CommandPalette } from './components/modals/CommandPalette';
import { Toaster } from './components/ui/sonner'; import { Toaster } from './components/ui/sonner';
import { ReplPanel } from './components/tools/ReplPanel'; import { ReplPanel } from './components/tools/ReplPanel';
import { PrintPreview } from './components/tools/PrintPreview'; import { PrintPreview } from './components/tools/PrintPreview';
@@ -30,6 +31,7 @@ function App() {
<> <>
<AppShell /> <AppShell />
<ModalLayer /> <ModalLayer />
<CommandPalette />
<Toaster /> <Toaster />
<UpdateBanner /> <UpdateBanner />
<FirstRunWizard /> <FirstRunWizard />
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { EditorState, Compartment } from '@codemirror/state'; import { EditorState, Compartment } from '@codemirror/state';
import { import {
EditorView, EditorView,
@@ -16,8 +16,10 @@ import { useTheme } from 'next-themes';
import { lightTheme, lightHighlight } from './themes/light'; import { lightTheme, lightHighlight } from './themes/light';
import { useEditorStore } from '@/stores/editor-store'; import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { setActiveView } from '@/lib/editor-commands'; import { setActiveView, insertSnippet } from '@/lib/editor-commands';
import { Minimap } from './Minimap'; import { Minimap } from './Minimap';
import { toast } from '@/lib/toast';
import { cn } from '@/lib/utils';
interface Props { interface Props {
bufferId: string; bufferId: string;
@@ -26,6 +28,63 @@ interface Props {
onCursorChange?: (line: number, column: number) => void; onCursorChange?: (line: number, column: number) => void;
} }
const IMAGE_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/svg+xml',
'image/bmp',
'image/avif',
]);
function guessExt(mimeType: string): string {
const map: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
'image/bmp': 'bmp',
'image/avif': 'avif',
};
return map[mimeType] ?? 'png';
}
async function handleImageFile(file: File): Promise<void> {
const reader = new FileReader();
reader.onload = async () => {
const base64 = (reader.result as string).split(',')[1];
if (!base64) return;
const ext = guessExt(file.type);
const result = await window.electronAPI.savePastedImage({ base64, ext });
if (result) {
insertSnippet(`![${file.name}](${result.relativePath})`);
toast.success('Image pasted');
}
};
reader.readAsDataURL(file);
}
function createPasteHandler() {
return EditorView.domEventHandlers({
paste(event: ClipboardEvent, _view: EditorView) {
const items = event.clipboardData?.items;
if (!items) return false;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (IMAGE_TYPES.has(item.type)) {
event.preventDefault();
const file = item.getAsFile();
if (file) handleImageFile(file);
return true;
}
}
return false;
},
});
}
export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) { export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
@@ -35,10 +94,11 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
const setCursor = useEditorStore((s) => s.setCursor); const setCursor = useEditorStore((s) => s.setCursor);
const minimap = useSettingsStore((s) => s.minimap); const minimap = useSettingsStore((s) => s.minimap);
const editorFontSize = useSettingsStore((s) => s.editorFontSize); const editorFontSize = useSettingsStore((s) => s.editorFontSize);
// Live scroll metrics for the minimap. `0..1` ratios; defaults match const buffer = useEditorStore((s) => s.buffers.get(bufferId));
// Minimap's static defaults so it renders before any scroll event. const content = buffer?.content ?? initialContent;
const [scrollRatio, setScrollRatio] = useState(0); const [scrollRatio, setScrollRatio] = useState(0);
const [visibleRatio, setVisibleRatio] = useState(1); const [visibleRatio, setVisibleRatio] = useState(1);
const [isDragOver, setIsDragOver] = useState(false);
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
@@ -92,6 +152,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
return false; return false;
}, },
}), }),
createPasteHandler(),
], ],
}); });
const view = new EditorView({ state, parent: ref.current }); const view = new EditorView({ state, parent: ref.current });
@@ -115,11 +176,42 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
}); });
}, [resolvedTheme]); }, [resolvedTheme]);
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
setIsDragOver(true);
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter((f) => IMAGE_TYPES.has(f.type));
for (const file of imageFiles) {
await handleImageFile(file);
}
}, []);
return ( return (
<div className="relative h-full overflow-hidden"> <div className="relative h-full overflow-hidden">
<div ref={ref} className="h-full overflow-hidden" /> <div
ref={ref}
className={cn(
'h-full overflow-hidden transition-colors duration-150',
isDragOver && 'ring-2 ring-primary bg-primary/5'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
/>
{minimap && ( {minimap && (
<Minimap content={initialContent} scrollRatio={scrollRatio} visibleRatio={visibleRatio} /> <Minimap content={content} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
)} )}
</div> </div>
); );
@@ -1,5 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { CodeMirrorEditor } from './CodeMirrorEditor'; import { CodeMirrorEditor } from './CodeMirrorEditor';
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';
@@ -21,8 +22,11 @@ export function EditorPane() {
} }
return ( return (
<div className="h-full"> <div className="flex h-full flex-col">
<FindReplaceBar />
<div className="flex-1 overflow-hidden">
<CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} /> <CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} />
</div> </div>
</div>
); );
} }
@@ -0,0 +1,246 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import {
findNext,
findPrevious,
replaceNext,
replaceAll,
closeSearchPanel,
getSearchQuery,
setSearchQuery,
} from '@codemirror/search';
import { getActiveView } from '@/lib/editor-commands';
import { useAppStore } from '@/stores/app-store';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { X, ChevronDown, ChevronUp, Replace, ReplaceAll, CaseSensitive, Regex } from 'lucide-react';
export function FindReplaceBar() {
const findBarOpen = useAppStore((s) => s.findBarOpen);
const toggleFindBar = useAppStore((s) => s.toggleFindBar);
const searchRef = useRef<HTMLInputElement>(null);
const replaceRef = useRef<HTMLInputElement>(null);
const [caseSensitive, setCaseSensitive] = useState(false);
const [useRegex, setUseRegex] = useState(false);
const [matchInfo, setMatchInfo] = useState<{ current: number; total: number } | null>(null);
const executeCommand = useCallback((fn: (view: any) => any) => {
const view = getActiveView();
if (!view) return false;
const result = fn(view);
updateMatchCount();
view.focus();
return result;
}, []);
const updateMatchCount = useCallback(() => {
const view = getActiveView();
if (!view) {
setMatchInfo(null);
return;
}
const query = getSearchQuery(view.state);
if (!query) {
setMatchInfo(null);
return;
}
const searchState = view.state.field(
// @ts-expect-error - internal field accessor
query.spec.create() &&
(() => {
for (const facet of view.state.field) return null;
})(),
false
);
try {
// @ts-expect-error - search state inspection
const panel = view.state.facet?.(searchPanelFacet)?.[0];
setMatchInfo(null);
} catch {
setMatchInfo(null);
}
}, []);
const handleFindNext = useCallback(() => {
executeCommand(findNext);
}, [executeCommand]);
const handleFindPrev = useCallback(() => {
executeCommand(findPrevious);
}, [executeCommand]);
const handleReplace = useCallback(() => {
executeCommand(replaceNext);
}, [executeCommand]);
const handleReplaceAll = useCallback(() => {
executeCommand(replaceAll);
}, [executeCommand]);
const handleClose = useCallback(() => {
const view = getActiveView();
if (view) closeSearchPanel(view);
toggleFindBar();
view?.focus();
}, [toggleFindBar]);
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const view = getActiveView();
if (!view) return;
setSearchQuery(view, {
search: value,
caseSensitive,
regexp: useRegex,
});
},
[caseSensitive, useRegex]
);
const handleReplaceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const view = getActiveView();
if (!view) return;
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive: query.caseSensitive ?? false,
regexp: query.regexp ?? false,
replace: e.target.value,
});
}
}, []);
useEffect(() => {
if (findBarOpen) {
setTimeout(() => searchRef.current?.focus(), 50);
}
}, [findBarOpen]);
useEffect(() => {
const handler = () => {
if (!useAppStore.getState().findBarOpen) {
useAppStore.getState().toggleFindBar();
}
};
window.addEventListener('mc:find-toggle', handler);
return () => window.removeEventListener('mc:find-toggle', handler);
}, []);
if (!findBarOpen) return null;
return (
<div className="flex items-center gap-1.5 border-b border-border bg-background px-2 py-1">
<Input
ref={searchRef}
placeholder="Find..."
className="h-7 w-48 text-xs"
onChange={handleSearchChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.shiftKey ? handleFindPrev() : handleFindNext();
}
if (e.key === 'Escape') handleClose();
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
replaceRef.current?.focus();
}
}}
data-testid="find-input"
/>
<button
type="button"
className={cn(
'rounded p-0.5 hover:bg-accent',
caseSensitive && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setCaseSensitive((v) => {
const next = !v;
const view = getActiveView();
if (view) {
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive: next,
regexp: useRegex,
replace: query.replace,
});
}
}
return next;
});
}}
aria-label="Case sensitive"
data-testid="find-case-sensitive"
>
<CaseSensitive className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(
'rounded p-0.5 hover:bg-accent',
useRegex && 'bg-accent text-accent-foreground'
)}
onClick={() => {
setUseRegex((v) => {
const next = !v;
const view = getActiveView();
if (view) {
const query = getSearchQuery(view.state);
if (query) {
setSearchQuery(view, {
search: query.search,
caseSensitive,
regexp: next,
replace: query.replace,
});
}
}
return next;
});
}}
aria-label="Use regex"
data-testid="find-regex"
>
<Regex className="h-3.5 w-3.5" />
</button>
<Input
ref={replaceRef}
placeholder="Replace..."
className="h-7 w-48 text-xs"
onChange={handleReplaceChange}
onKeyDown={(e) => {
if (e.key === 'Enter') handleReplace();
if (e.key === 'Escape') handleClose();
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
searchRef.current?.focus();
}
}}
data-testid="replace-input"
/>
<Button size="sm" variant="ghost" onClick={handleFindPrev} aria-label="Find previous">
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleFindNext} aria-label="Find next">
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleReplace} aria-label="Replace">
<Replace className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleReplaceAll} aria-label="Replace all">
<ReplaceAll className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={handleClose} aria-label="Close">
<X className="h-3.5 w-3.5" />
</Button>
</div>
);
}
@@ -0,0 +1,290 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useAppStore } from '@/stores/app-store';
import { ExportDialogFooter } from './ExportDialogFooter';
import { toast } from '@/lib/toast';
type ToolKey = 'imagemagick' | 'ffmpeg';
interface FormatOption {
value: string;
label: string;
}
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
imagemagick: {
input: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'tiff', label: 'TIFF' },
{ value: 'svg', label: 'SVG' },
{ value: 'ico', label: 'ICO' },
],
output: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'pdf', label: 'PDF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'ico', label: 'ICO' },
],
},
ffmpeg: {
input: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'mov', label: 'MOV' },
],
output: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'gif', label: 'GIF' },
{ value: 'webm', label: 'WebM' },
],
},
};
const toolLabels: Record<ToolKey, string> = {
imagemagick: 'ImageMagick',
ffmpeg: 'FFmpeg',
};
export function BatchMediaConverterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [tool, setTool] = useState<ToolKey>('imagemagick');
const [fromFormat, setFromFormat] = useState('');
const [toFormat, setToFormat] = useState('');
const [inputFolder, setInputFolder] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [includeSubfolders, setIncludeSubfolders] = useState(false);
const [converting, setConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputFormats = converterFormats[tool].input;
const outputFormats = converterFormats[tool].output;
useEffect(() => {
setFromFormat('');
setToFormat('');
}, [tool]);
const handleBrowseInputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setInputFolder(result);
}
}, []);
const handleBrowseOutputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setOutputFolder(result);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const handlers: Array<() => void> = [];
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
setProgress(Math.round((data.current / data.total) * 100));
}
};
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
setConverting(false);
setProgress(100);
if (data?.error) {
setError(data.error);
toast.error(`Batch conversion failed: ${data.error}`);
} else {
toast.success('Batch conversion complete');
closeModal();
}
};
const unsubBatch =
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
const unsubComplete =
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
handlers.push(unsubBatch, unsubComplete);
return () => handlers.forEach((h) => h());
}, [closeModal]);
const handleConvert = async () => {
if (!fromFormat || !toFormat) {
setError('Select both source and target formats');
return;
}
if (!inputFolder || !outputFolder) {
setError('Select both input and output folders');
return;
}
setConverting(true);
setProgress(0);
setError(null);
try {
await window.electronAPI?.converter?.convertBatch?.({
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
includeSubfolders,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
toast.error(`Batch conversion failed: ${msg}`);
setConverting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Batch Media Converter</DialogTitle>
<DialogDescription>
Convert multiple media files between formats using ImageMagick or FFmpeg
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
<Tabs value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
<TabsList>
<TabsTrigger value="imagemagick">ImageMagick</TabsTrigger>
<TabsTrigger value="ffmpeg">FFmpeg</TabsTrigger>
</TabsList>
<TabsContent value={tool} className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="batch-from">From</Label>
<Select value={fromFormat} onValueChange={setFromFormat}>
<SelectTrigger id="batch-from" aria-label="Source format">
<SelectValue placeholder="Source format" />
</SelectTrigger>
<SelectContent>
{inputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="batch-to">To</Label>
<Select value={toFormat} onValueChange={setToFormat}>
<SelectTrigger id="batch-to" aria-label="Target format">
<SelectValue placeholder="Target format" />
</SelectTrigger>
<SelectContent>
{outputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Input
value={inputFolder}
onChange={(e) => setInputFolder(e.target.value)}
placeholder="Input folder"
className="flex-1"
aria-label="Input folder"
/>
<Button variant="outline" onClick={handleBrowseInputFolder}>
Browse
</Button>
</div>
<div className="flex items-center gap-2">
<Input
value={outputFolder}
onChange={(e) => setOutputFolder(e.target.value)}
placeholder="Output folder"
className="flex-1"
aria-label="Output folder"
/>
<Button variant="outline" onClick={handleBrowseOutputFolder}>
Browse
</Button>
</div>
<div className="flex items-center gap-3">
<Switch checked={includeSubfolders} onCheckedChange={setIncludeSubfolders} id="batch-subfolders" />
<Label htmlFor="batch-subfolders">Include subdirectories</Label>
</div>
</TabsContent>
</Tabs>
{converting && (
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.max(2, progress)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
</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={handleConvert}
submitting={converting}
submitLabel="Convert"
/>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useCommandStore } from '@/stores/command-store';
import { cn } from '@/lib/utils';
interface CommandItem {
id: string;
label: string;
shortcut: string | null;
}
const MAX_RESULTS = 8;
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const commands = useMemo<CommandItem[]>(() => {
const handlers = useCommandStore.getState().handlers;
return Object.keys(handlers).map((id) => ({
id,
label: id
.split('.')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' → '),
shortcut: null,
}));
}, []);
const filtered = useMemo(() => {
if (!query.trim()) return commands.slice(0, MAX_RESULTS);
const lower = query.toLowerCase();
const scored = commands
.map((cmd) => {
const labelLower = cmd.label.toLowerCase();
const idLower = cmd.id.toLowerCase();
const labelIdx = labelLower.indexOf(lower);
const idIdx = idLower.indexOf(lower);
let score = 100;
if (labelIdx === 0) score = 10;
else if (labelIdx >= 0) score = 20;
else if (idIdx === 0) score = 15;
else if (idIdx >= 0) score = 25;
else score = 100;
return { cmd, score };
})
.filter((item) => item.score < 100)
.sort((a, b) => a.score - b.score)
.map((item) => item.cmd);
return scored.slice(0, MAX_RESULTS);
}, [commands, query]);
useEffect(() => {
if (open) {
setSelectedIndex(0);
setQuery('');
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
const execute = useCallback((id: string) => {
useCommandStore.getState().dispatch(id);
setOpen(false);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[selectedIndex];
if (cmd) execute(cmd.id);
} else if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
}
},
[filtered, selectedIndex, execute]
);
useEffect(() => {
const handle = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
setOpen((o) => !o);
}
};
window.addEventListener('keydown', handle);
return () => window.removeEventListener('keydown', handle);
}, []);
useEffect(() => {
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
el?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setOpen(false)} />
<div className="relative z-10 w-full max-w-lg rounded-lg border bg-background shadow-2xl">
<div className="border-b px-3">
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command..."
className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
aria-label="Search commands"
role="combobox"
aria-expanded="true"
aria-activedescendant={
filtered[selectedIndex] ? `cmd-${filtered[selectedIndex].id}` : undefined
}
/>
</div>
<div ref={listRef} className="max-h-64 overflow-y-auto p-1" role="listbox">
{filtered.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No matching commands
</div>
)}
{filtered.map((cmd, idx) => (
<button
key={cmd.id}
id={`cmd-${cmd.id}`}
role="option"
aria-selected={idx === selectedIndex}
className={cn(
'flex w-full items-center justify-between rounded-md px-3 py-2 text-sm',
idx === selectedIndex ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
)}
onClick={() => execute(cmd.id)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<span>{cmd.label}</span>
{cmd.shortcut && (
<kbd className="ml-4 rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
{cmd.shortcut}
</kbd>
)}
</button>
))}
</div>
</div>
</div>
);
}
@@ -15,6 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
@@ -29,6 +31,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
const source = useExportSource(); const source = useExportSource();
const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate); const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [referenceDoc, setReferenceDoc] = useState('');
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [bibliography, setBibliography] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -49,6 +56,17 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
setSubmitting(false); setSubmitting(false);
return; return;
} }
const options = {
referenceDoc: referenceDoc || undefined,
toc,
tocDepth,
numberSections,
bibliography: bibliography || undefined,
};
await window.electronAPI?.export?.withOptions?.('docx', options);
const buffer = new Uint8Array(await blob.arrayBuffer()); const buffer = new Uint8Array(await blob.arrayBuffer());
const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer }); const writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
if (!writeResult.ok) { if (!writeResult.ok) {
@@ -68,7 +86,7 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent> <DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to DOCX</DialogTitle> <DialogTitle>Export to DOCX</DialogTitle>
<DialogDescription>{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
@@ -99,6 +117,49 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
/> />
Render tables as ASCII Render tables as ASCII
</label> </label>
<div>
<Label htmlFor="docx-reference">Reference doc / template</Label>
<Input
id="docx-reference"
value={referenceDoc}
onChange={(e) => setReferenceDoc(e.target.value)}
placeholder="/path/to/reference.docx"
aria-label="Reference document path"
/>
</div>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="docx-toc" />
<Label htmlFor="docx-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="docx-toc-depth">TOC Depth</Label>
<Input
id="docx-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="docx-number-sections" />
<Label htmlFor="docx-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="docx-bibliography">Bibliography file</Label>
<Input
id="docx-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/references.bib"
aria-label="Bibliography file path"
/>
</div>
{error && ( {error && (
<div <div
role="alert" role="alert"
@@ -15,6 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
@@ -30,6 +32,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
const [standalone, setStandalone] = useState(true); const [standalone, setStandalone] = useState(true);
const [highlight, setHighlight] = useState(htmlHighlightStyle); const [highlight, setHighlight] = useState(htmlHighlightStyle);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [selfContained, setSelfContained] = useState(false);
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [cssPath, setCssPath] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -48,6 +55,19 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
highlightStyle: highlight, highlightStyle: highlight,
renderTablesAsAscii: ascii, renderTablesAsAscii: ascii,
}); });
const options = {
standalone,
highlightStyle: highlight,
selfContained,
toc,
tocDepth,
numberSections,
css: cssPath || undefined,
};
await window.electronAPI?.export?.withOptions?.('html', options);
const saveResult = await ipc.app.showSaveDialog?.({ const saveResult = await ipc.app.showSaveDialog?.({
title: 'Save as HTML', title: 'Save as HTML',
defaultPath: source.path.replace(/\.md$/, '.html'), defaultPath: source.path.replace(/\.md$/, '.html'),
@@ -75,7 +95,7 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent> <DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to HTML</DialogTitle> <DialogTitle>Export to HTML</DialogTitle>
<DialogDescription>{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
@@ -103,6 +123,10 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-3">
<Switch checked={selfContained} onCheckedChange={setSelfContained} id="html-self-contained" />
<Label htmlFor="html-self-contained">Self-contained (embed all CSS inline)</Label>
</div>
<label className="flex items-center gap-2"> <label className="flex items-center gap-2">
<Checkbox <Checkbox
checked={ascii} checked={ascii}
@@ -111,6 +135,39 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
/> />
Render tables as ASCII Render tables as ASCII
</label> </label>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="html-toc" />
<Label htmlFor="html-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="html-toc-depth">TOC Depth</Label>
<Input
id="html-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="html-number-sections" />
<Label htmlFor="html-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="html-css">Custom CSS file</Label>
<Input
id="html-css"
value={cssPath}
onChange={(e) => setCssPath(e.target.value)}
placeholder="/path/to/custom.css"
aria-label="Custom CSS file path"
/>
</div>
{error && ( {error && (
<div <div
role="alert" role="alert"
@@ -15,6 +15,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useExportSource } from '@/hooks/use-export-source'; import { useExportSource } from '@/hooks/use-export-source';
@@ -37,6 +39,15 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins); const [margins, setMargins] = useState<'normal' | 'narrow' | 'wide'>(pdfMargins);
const [embed, setEmbed] = useState(pdfEmbedFonts); const [embed, setEmbed] = useState(pdfEmbedFonts);
const [ascii, setAscii] = useState(renderTablesAsAscii); const [ascii, setAscii] = useState(renderTablesAsAscii);
const [engine, setEngine] = useState<'pdflatex' | 'xelatex' | 'lualatex'>('pdflatex');
const [toc, setToc] = useState(false);
const [tocDepth, setTocDepth] = useState(3);
const [numberSections, setNumberSections] = useState(false);
const [pageGeometry, setPageGeometry] = useState<'margin' | 'crop' | 'bleed'>('margin');
const [bibliography, setBibliography] = useState('');
const [mainFont, setMainFont] = useState('');
const [cjkFont, setCjkFont] = useState('');
const [highlightStyle, setHighlightStyle] = useState('tango');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const source = useExportSource(); const source = useExportSource();
@@ -49,8 +60,6 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
try { try {
// Renderer-side: build the standalone HTML and hand it to the main
// process for native print-to-PDF.
const html = generateHtml({ const html = generateHtml({
source: source.source, source: source.source,
title: source.title, title: source.title,
@@ -67,7 +76,24 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
const m = MARGIN_MAP[margins]; const m = MARGIN_MAP[margins];
const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`; const pageCss = `@page { size: ${fmt.width} ${fmt.height}; margin: ${m.top}mm ${m.right}mm ${m.bottom}mm ${m.left}mm; }`;
const finalHtml = html.replace('</style>', `${pageCss}</style>`); const finalHtml = html.replace('</style>', `${pageCss}</style>`);
const result = await ipc.print.show({ html: finalHtml, withStyles: embed });
const options = {
html: finalHtml,
withStyles: embed,
engine,
toc,
tocDepth,
numberSections,
pageGeometry,
bibliography: bibliography || undefined,
mainFont: mainFont || undefined,
cjkFont: cjkFont || undefined,
highlightStyle,
};
const result = await (window.electronAPI?.export?.withOptions?.('pdf', options) ??
ipc.print.show({ html: finalHtml, withStyles: embed }));
if (!result.ok) { if (!result.ok) {
const msg = result.error?.message ?? 'PDF export failed'; const msg = result.error?.message ?? 'PDF export failed';
toast.error(`Export failed: ${msg}`); toast.error(`Export failed: ${msg}`);
@@ -87,7 +113,7 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
return ( return (
<Dialog open onOpenChange={(o) => !o && closeModal()}> <Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent> <DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Export to PDF</DialogTitle> <DialogTitle>Export to PDF</DialogTitle>
<DialogDescription>{sourcePath}</DialogDescription> <DialogDescription>{sourcePath}</DialogDescription>
@@ -135,6 +161,99 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
/> />
Render tables as ASCII Render tables as ASCII
</label> </label>
<div>
<Label htmlFor="pdf-engine">PDF Engine</Label>
<Select value={engine} onValueChange={(v) => setEngine(v as typeof engine)}>
<SelectTrigger id="pdf-engine" aria-label="PDF Engine">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pdflatex">pdflatex</SelectItem>
<SelectItem value="xelatex">xelatex</SelectItem>
<SelectItem value="lualatex">lualatex</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Switch checked={toc} onCheckedChange={setToc} id="pdf-toc" />
<Label htmlFor="pdf-toc">Table of Contents</Label>
</div>
{toc && (
<div className="pl-9">
<Label htmlFor="pdf-toc-depth">TOC Depth</Label>
<Input
id="pdf-toc-depth"
type="number"
min={1}
max={6}
value={tocDepth}
onChange={(e) => setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))}
className="w-20"
aria-label="TOC depth"
/>
</div>
)}
<div className="flex items-center gap-3">
<Switch checked={numberSections} onCheckedChange={setNumberSections} id="pdf-number-sections" />
<Label htmlFor="pdf-number-sections">Number sections</Label>
</div>
<div>
<Label htmlFor="pdf-page-geometry">Page Geometry</Label>
<Select value={pageGeometry} onValueChange={(v) => setPageGeometry(v as typeof pageGeometry)}>
<SelectTrigger id="pdf-page-geometry" aria-label="Page geometry">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="margin">Margin</SelectItem>
<SelectItem value="crop">Crop</SelectItem>
<SelectItem value="bleed">Bleed</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pdf-bibliography">Bibliography file</Label>
<Input
id="pdf-bibliography"
value={bibliography}
onChange={(e) => setBibliography(e.target.value)}
placeholder="/path/to/references.bib"
aria-label="Bibliography file path"
/>
</div>
<div>
<Label htmlFor="pdf-main-font">Main font</Label>
<Input
id="pdf-main-font"
value={mainFont}
onChange={(e) => setMainFont(e.target.value)}
placeholder="e.g. Latin Modern"
aria-label="Main font"
/>
</div>
<div>
<Label htmlFor="pdf-cjk-font">CJK font</Label>
<Input
id="pdf-cjk-font"
value={cjkFont}
onChange={(e) => setCjkFont(e.target.value)}
placeholder="e.g. Noto Sans CJK SC"
aria-label="CJK font"
/>
</div>
<div>
<Label htmlFor="pdf-highlight">Highlight style</Label>
<Select value={highlightStyle} onValueChange={setHighlightStyle}>
<SelectTrigger id="pdf-highlight" aria-label="Highlight style">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tango">Tango</SelectItem>
<SelectItem value="pygments">Pygments</SelectItem>
<SelectItem value="kateks">Kate (Kateks)</SelectItem>
<SelectItem value="monochrome">Monochrome</SelectItem>
</SelectContent>
</Select>
</div>
{error && ( {error && (
<div <div
role="alert" role="alert"
@@ -0,0 +1,299 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAppStore } from '@/stores/app-store';
import { toast } from '@/lib/toast';
interface HeaderFooterSettings {
headerEnabled: boolean;
footerEnabled: boolean;
headerLeft: string;
headerCenter: string;
headerRight: string;
footerLeft: string;
footerCenter: string;
footerRight: string;
logoPosition: 'none' | 'left' | 'right';
logoPath: string | null;
}
const DYNAMIC_FIELDS = [
{ token: '$PAGE$', label: 'Page' },
{ token: '$TOTAL$', label: 'Total Pages' },
{ token: '$DATE$', label: 'Date' },
{ token: '$TIME$', label: 'Time' },
{ token: '$TITLE$', label: 'Title' },
{ token: '$AUTHOR$', label: 'Author' },
{ token: '$FILENAME$', label: 'Filename' },
] as const;
const DEFAULTS: HeaderFooterSettings = {
headerEnabled: false,
footerEnabled: false,
headerLeft: '',
headerCenter: '',
headerRight: '',
footerLeft: '',
footerCenter: '',
footerRight: '',
logoPosition: 'none',
logoPath: null,
};
type FieldKey = keyof Pick<
HeaderFooterSettings,
'headerLeft' | 'headerCenter' | 'headerRight' | 'footerLeft' | 'footerCenter' | 'footerRight'
>;
export function HeaderFooterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [settings, setSettings] = useState<HeaderFooterSettings>(DEFAULTS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let mounted = true;
async function load() {
try {
const result = await window.electronAPI?.headerFooter?.getSettings?.();
if (mounted && result && typeof result === 'object') {
setSettings({ ...DEFAULTS, ...result });
}
} finally {
if (mounted) setLoading(false);
}
}
void load();
return () => {
mounted = false;
};
}, []);
const updateField = useCallback((key: FieldKey, value: string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const insertToken = useCallback((key: FieldKey, token: string) => {
setSettings((prev) => ({
...prev,
[key]: prev[key] + token,
}));
}, []);
const handleBrowseLogo = useCallback(async () => {
const result = await window.electronAPI?.headerFooter?.browseLogo?.();
if (typeof result === 'string') {
setSettings((prev) => ({
...prev,
logoPath: result,
logoPosition: prev.logoPosition === 'none' ? 'left' : prev.logoPosition,
}));
}
}, []);
const handleClearLogo = useCallback(() => {
setSettings((prev) => ({ ...prev, logoPath: null, logoPosition: 'none' }));
}, []);
const handleSave = async () => {
setSaving(true);
try {
await window.electronAPI?.headerFooter?.saveSettings?.(settings);
toast.success('Header & footer settings saved');
closeModal();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(`Failed to save: ${msg}`);
} finally {
setSaving(false);
}
};
const fieldRows: Array<{ key: FieldKey; label: string; enabled: boolean }> = [
{ key: 'headerLeft', label: 'Left', enabled: settings.headerEnabled },
{ key: 'headerCenter', label: 'Center', enabled: settings.headerEnabled },
{ key: 'headerRight', label: 'Right', enabled: settings.headerEnabled },
{ key: 'footerLeft', label: 'Left', enabled: settings.footerEnabled },
{ key: 'footerCenter', label: 'Center', enabled: settings.footerEnabled },
{ key: 'footerRight', label: 'Right', enabled: settings.footerEnabled },
];
if (loading) return null;
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Header & Footer</DialogTitle>
<DialogDescription>
Configure headers and footers for exported documents
</DialogDescription>
</DialogHeader>
<div className="space-y-4 text-sm">
<label className="flex items-center gap-2">
<Checkbox
checked={settings.headerEnabled}
onCheckedChange={(c) => setSettings((p) => ({ ...p, headerEnabled: !!c }))}
aria-label="Enable header"
/>
Enable header
</label>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Header</p>
{fieldRows
.filter((r) => r.key.startsWith('header'))
.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
<Input
value={settings[row.key]}
onChange={(e) => updateField(row.key, e.target.value)}
placeholder={`Header ${row.label.toLowerCase()}`}
className="flex-1"
disabled={!settings.headerEnabled}
aria-label={`Header ${row.label.toLowerCase()}`}
/>
<div className="flex gap-0.5">
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
<Button
key={field.token}
variant="ghost"
size="sm"
className="h-7 px-1.5 text-[10px]"
disabled={!settings.headerEnabled}
onClick={() => insertToken(row.key, field.token)}
title={field.label}
>
{field.token}
</Button>
))}
</div>
</div>
))}
</div>
<label className="flex items-center gap-2">
<Checkbox
checked={settings.footerEnabled}
onCheckedChange={(c) => setSettings((p) => ({ ...p, footerEnabled: !!c }))}
aria-label="Enable footer"
/>
Enable footer
</label>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Footer</p>
{fieldRows
.filter((r) => r.key.startsWith('footer'))
.map((row) => (
<div key={row.key} className="flex items-center gap-2">
<span className="w-12 text-xs text-muted-foreground">{row.label}</span>
<Input
value={settings[row.key]}
onChange={(e) => updateField(row.key, e.target.value)}
placeholder={`Footer ${row.label.toLowerCase()}`}
className="flex-1"
disabled={!settings.footerEnabled}
aria-label={`Footer ${row.label.toLowerCase()}`}
/>
<div className="flex gap-0.5">
{DYNAMIC_FIELDS.slice(0, 4).map((field) => (
<Button
key={field.token}
variant="ghost"
size="sm"
className="h-7 px-1.5 text-[10px]"
disabled={!settings.footerEnabled}
onClick={() => insertToken(row.key, field.token)}
title={field.label}
>
{field.token}
</Button>
))}
</div>
</div>
))}
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Logo</p>
<div className="flex items-center gap-2">
<Select
value={settings.logoPosition}
onValueChange={(v) =>
setSettings((p) => ({
...p,
logoPosition: v as HeaderFooterSettings['logoPosition'],
}))
}
>
<SelectTrigger className="w-32" aria-label="Logo position">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
{settings.logoPosition !== 'none' && (
<>
<Input
value={settings.logoPath ?? ''}
readOnly
placeholder="No logo selected"
className="flex-1"
aria-label="Logo path"
/>
<Button variant="outline" size="sm" onClick={handleBrowseLogo}>
Browse
</Button>
{settings.logoPath && (
<Button variant="ghost" size="sm" onClick={handleClearLogo}>
Clear
</Button>
)}
</>
)}
</div>
{settings.logoPath && (
<img
src={`file://${settings.logoPath}`}
alt="Logo preview"
className="h-8 rounded border object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeModal} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,6 @@
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { AboutDialog } from './AboutDialog'; import { AboutDialog } from './AboutDialog';
import { BatchMediaConverterDialog } from './BatchMediaConverterDialog';
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog'; import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
import { ConfirmDialog } from './ConfirmDialog'; import { ConfirmDialog } from './ConfirmDialog';
import { CrashReportModal } from './CrashReportModal'; import { CrashReportModal } from './CrashReportModal';
@@ -8,11 +9,14 @@ import { ExportDocxDialog } from './ExportDocxDialog';
import { ExportHtmlDialog } from './ExportHtmlDialog'; import { ExportHtmlDialog } from './ExportHtmlDialog';
import { ExportPdfDialog } from './ExportPdfDialog'; import { ExportPdfDialog } from './ExportPdfDialog';
import { FindInFilesDialog } from './FindInFilesDialog'; import { FindInFilesDialog } from './FindInFilesDialog';
import { HeaderFooterDialog } from './HeaderFooterDialog';
import { SettingsSheet } from './SettingsSheet'; import { SettingsSheet } from './SettingsSheet';
import { TableGeneratorDialog } from './TableGeneratorDialog'; import { TableGeneratorDialog } from './TableGeneratorDialog';
import { UniversalConverterDialog } from './UniversalConverterDialog';
import { WelcomeDialog } from './WelcomeDialog'; import { WelcomeDialog } from './WelcomeDialog';
import { WordExportDialog } from './WordExportDialog'; import { WordExportDialog } from './WordExportDialog';
import { WritingAnalyticsDialog } from './WritingAnalyticsDialog'; import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
import { PdfEditorDialog } from './PdfEditorDialog';
export function ModalLayer() { export function ModalLayer() {
const modal = useAppStore((s) => s.modal); const modal = useAppStore((s) => s.modal);
@@ -47,5 +51,18 @@ export function ModalLayer() {
return <CrashReportModal onClose={useAppStore.getState().closeModal} />; return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
case 'writing-analytics': case 'writing-analytics':
return <WritingAnalyticsDialog />; return <WritingAnalyticsDialog />;
case 'pdf-editor':
return (
<PdfEditorDialog
onClose={useAppStore.getState().closeModal}
initialFilePath={modal.props?.filePath}
/>
);
case 'universal-converter':
return <UniversalConverterDialog />;
case 'header-footer':
return <HeaderFooterDialog />;
case 'batch-media-converter':
return <BatchMediaConverterDialog />;
} }
} }
@@ -0,0 +1,988 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Slider } from '@/components/ui/slider';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/lib/toast';
import {
FilePlus2,
Scissors,
FileDown,
RotateCw,
Trash2,
ArrowUpDown,
Droplets,
Lock,
Unlock,
Shield,
Loader2,
FolderOpen,
File,
X,
} from 'lucide-react';
type Operation =
| 'merge'
| 'split'
| 'compress'
| 'rotate'
| 'delete'
| 'reorder'
| 'watermark'
| 'encrypt'
| 'decrypt'
| 'permissions';
interface OperationConfig {
id: Operation;
label: string;
icon: React.ReactNode;
needsInput: boolean;
needsOutput: boolean;
needsFolder: boolean;
multiInput: boolean;
}
const OPERATIONS: OperationConfig[] = [
{
id: 'merge',
label: 'Merge',
icon: <FilePlus2 className="size-3.5" />,
needsInput: false,
needsOutput: true,
needsFolder: false,
multiInput: true,
},
{
id: 'split',
label: 'Split',
icon: <Scissors className="size-3.5" />,
needsInput: true,
needsOutput: false,
needsFolder: true,
multiInput: false,
},
{
id: 'compress',
label: 'Compress',
icon: <FileDown className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'rotate',
label: 'Rotate',
icon: <RotateCw className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'delete',
label: 'Delete',
icon: <Trash2 className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'reorder',
label: 'Reorder',
icon: <ArrowUpDown className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'watermark',
label: 'Watermark',
icon: <Droplets className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'encrypt',
label: 'Encrypt',
icon: <Lock className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'decrypt',
label: 'Decrypt',
icon: <Unlock className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
{
id: 'permissions',
label: 'Permissions',
icon: <Shield className="size-3.5" />,
needsInput: true,
needsOutput: true,
needsFolder: false,
multiInput: false,
},
];
const WATERMARK_POSITIONS = [
'center',
'top-left',
'top-center',
'top-right',
'middle-left',
'middle-right',
'bottom-left',
'bottom-center',
'bottom-right',
] as const;
const PERMISSION_OPTIONS = [
{ key: 'printing', label: 'Printing' },
{ key: 'modifying', label: 'Modifying' },
{ key: 'copying', label: 'Copying' },
{ key: 'annotating', label: 'Annotating' },
{ key: 'filling', label: 'Form filling' },
{ key: 'extracting', label: 'Content extraction' },
{ key: 'assembling', label: 'Document assembly' },
] as const;
const SPLIT_MODES = ['ranges', 'interval', 'size'] as const;
type SplitMode = (typeof SPLIT_MODES)[number];
interface Props {
onClose: () => void;
initialFilePath?: string;
}
export function PdfEditorDialog({ onClose, initialFilePath }: Props) {
const [activeTab, setActiveTab] = useState<Operation>('merge');
const [submitting, setSubmitting] = useState(false);
const [progress, setProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [inputPath, setInputPath] = useState(initialFilePath ?? '');
const [outputPath, setOutputPath] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [mergeFiles, setMergeFiles] = useState<string[]>([]);
const [pages, setPages] = useState('');
const [rotateAngle, setRotateAngle] = useState('90');
const [newOrder, setNewOrder] = useState('');
const [splitMode, setSplitMode] = useState<SplitMode>('ranges');
const [pageRanges, setPageRanges] = useState('');
const [splitInterval, setSplitInterval] = useState('5');
const [splitSize, setSplitSize] = useState('1');
const [compressLevel, setCompressLevel] = useState(50);
const [compressImages, setCompressImages] = useState(true);
const [compressDuplicates, setCompressDuplicates] = useState(true);
const [compressFonts, setCompressFonts] = useState(true);
const [watermarkText, setWatermarkText] = useState('');
const [watermarkFontSize, setWatermarkFontSize] = useState('48');
const [watermarkOpacity, setWatermarkOpacity] = useState(30);
const [watermarkPosition, setWatermarkPosition] = useState('center');
const [watermarkColor, setWatermarkColor] = useState('#ff0000');
const [watermarkPages, setWatermarkPages] = useState('');
const [userPassword, setUserPassword] = useState('');
const [ownerPassword, setOwnerPassword] = useState('');
const [encryptionLevel, setEncryptionLevel] = useState('128');
const [encryptPermissions, setEncryptPermissions] = useState<Record<string, boolean>>({});
const [decryptPassword, setDecryptPassword] = useState('');
const [permissionsPassword, setPermissionsPassword] = useState('');
const [permissions, setPermissions] = useState<Record<string, boolean>>({});
useEffect(() => {
if (initialFilePath) setInputPath(initialFilePath);
}, [initialFilePath]);
const handleProgress = useCallback((e: Event) => {
const detail = (e as CustomEvent).detail;
setProgress(detail.message);
}, []);
const handleComplete = useCallback(
(e: Event) => {
const detail = (e as CustomEvent).detail;
setSubmitting(false);
setProgress(null);
if (detail.success) {
toast.success(detail.message || 'Operation completed successfully');
onClose();
} else {
const msg = detail.error || 'Operation failed';
toast.error(msg);
setError(msg);
}
},
[onClose]
);
useEffect(() => {
window.addEventListener('pdf-operation-progress', handleProgress);
window.addEventListener('pdf-operation-complete', handleComplete);
return () => {
window.removeEventListener('pdf-operation-progress', handleProgress);
window.removeEventListener('pdf-operation-complete', handleComplete);
};
}, [handleProgress, handleComplete]);
const pickFile = async (): Promise<string | null> => {
const r = await window.electronAPI?.file?.pickFile();
return r?.data ?? null;
};
const pickFolder = async (): Promise<string | null> => {
const r = await window.electronAPI?.file?.pickFolder();
return r?.data ?? null;
};
const handleAddMergeFiles = async () => {
const path = await pickFile();
if (path) setMergeFiles((prev) => [...prev, path]);
};
const handleRemoveMergeFile = (index: number) => {
setMergeFiles((prev) => prev.filter((_, i) => i !== index));
};
const handlePickInput = async () => {
const path = await pickFile();
if (path) setInputPath(path);
};
const handlePickOutput = async () => {
const r = await window.electronAPI?.app?.showSaveDialog({
title: 'Save output PDF',
defaultPath: outputPath || 'output.pdf',
});
if (r?.ok && r.data) setOutputPath(r.data);
};
const handlePickFolder = async () => {
const path = await pickFolder();
if (path) setOutputFolder(path);
};
const buildPayload = (): Record<string, unknown> => {
const base = { operation: activeTab };
switch (activeTab) {
case 'merge':
return { ...base, inputPaths: mergeFiles, outputPath };
case 'split': {
const splitData: Record<string, unknown> = {
...base,
inputPath,
outputFolder,
mode: splitMode,
};
if (splitMode === 'ranges') splitData.pageRanges = pageRanges;
if (splitMode === 'interval') splitData.interval = Number(splitInterval);
if (splitMode === 'size') splitData.maxSizeMB = Number(splitSize);
return splitData;
}
case 'compress':
return {
...base,
inputPath,
outputPath,
compressionLevel: compressLevel,
compressImages,
removeDuplicates: compressDuplicates,
subsetFonts: compressFonts,
};
case 'rotate':
return { ...base, inputPath, outputPath, pages, angle: Number(rotateAngle) };
case 'delete':
return { ...base, inputPath, outputPath, pages };
case 'reorder':
return {
...base,
inputPath,
outputPath,
newOrder: newOrder
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
case 'watermark':
return {
...base,
inputPath,
outputPath,
text: watermarkText,
fontSize: Number(watermarkFontSize),
opacity: watermarkOpacity / 100,
position: watermarkPosition,
color: watermarkColor,
pages: watermarkPages,
};
case 'encrypt':
return {
...base,
inputPath,
outputPath,
userPassword,
ownerPassword,
encryptionLevel: Number(encryptionLevel),
permissions: encryptPermissions,
};
case 'decrypt':
return { ...base, inputPath, outputPath, password: decryptPassword };
case 'permissions':
return { ...base, inputPath, outputPath, ownerPassword: permissionsPassword, permissions };
default:
return base;
}
};
const validate = (): string | null => {
const op = OPERATIONS.find((o) => o.id === activeTab)!;
if (op.multiInput && mergeFiles.length < 2) return 'Add at least 2 PDF files to merge';
if (op.needsInput && !inputPath) return 'Select an input PDF file';
if (op.needsOutput && !outputPath) return 'Select an output path';
if (op.needsFolder && !outputFolder) return 'Select an output folder';
if (activeTab === 'watermark' && !watermarkText) return 'Enter watermark text';
if (activeTab === 'encrypt' && !userPassword) return 'Enter a user password';
if (activeTab === 'decrypt' && !decryptPassword) return 'Enter the password';
if (activeTab === 'permissions' && !permissionsPassword) return 'Enter the owner password';
if (activeTab === 'split' && splitMode === 'ranges' && !pageRanges) return 'Enter page ranges';
return null;
};
const handleSubmit = async () => {
const validationError = validate();
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
setError(null);
setProgress('Starting operation...');
try {
const payload = buildPayload();
await window.electronAPI?.pdf?.processOperation?.(payload);
} catch (err) {
setSubmitting(false);
setProgress(null);
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
setError(msg);
}
};
const renderFileField = (
label: string,
value: string,
onChange: (v: string) => void,
onPick: () => void,
placeholder = ''
) => (
<div className="space-y-1.5">
<Label>{label}</Label>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={onPick}>
<File className="size-3.5" />
</Button>
</div>
</div>
);
const renderFolderField = (
label: string,
value: string,
onChange: (v: string) => void,
onPick: () => void,
placeholder = ''
) => (
<div className="space-y-1.5">
<Label>{label}</Label>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={onPick}>
<FolderOpen className="size-3.5" />
</Button>
</div>
</div>
);
const renderError = () =>
error ? (
<div
role="alert"
className="rounded border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
>
{error}
</div>
) : null;
const renderProgress = () =>
progress ? (
<div className="flex items-center gap-2 rounded border border-primary/40 bg-primary/5 p-2 text-xs text-primary">
<Loader2 className="size-3.5 animate-spin" />
{progress}
</div>
) : null;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col">
<DialogHeader>
<DialogTitle>PDF Editor</DialogTitle>
<DialogDescription>Manipulate and transform PDF files</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(v) => {
setActiveTab(v as Operation);
setError(null);
}}
>
<TabsList className="flex-wrap">
{OPERATIONS.map((op) => (
<TabsTrigger key={op.id} value={op.id} className="gap-1 text-xs">
{op.icon}
<span className="hidden sm:inline">{op.label}</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex-1 overflow-y-auto">
<TabsContent value="merge" className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label>PDF files</Label>
<div className="flex flex-col gap-1.5">
{mergeFiles.map((f, i) => (
<div
key={i}
className="flex items-center gap-2 rounded border border-border bg-card/20 px-2 py-1 text-xs"
>
<span className="flex-1 truncate">{f}</span>
<Button
variant="ghost"
size="sm"
className="size-6 p-0"
onClick={() => handleRemoveMergeFile(i)}
>
<X className="size-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={handleAddMergeFiles}
disabled={submitting}
>
<FilePlus2 className="size-3.5" />
Add file
</Button>
</div>
</div>
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
{renderError()}
</TabsContent>
<TabsContent value="split" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFolderField(
'Output folder',
outputFolder,
setOutputFolder,
handlePickFolder,
'Select output folder'
)}
<div className="space-y-1.5">
<Label>Split mode</Label>
<Select value={splitMode} onValueChange={(v) => setSplitMode(v as SplitMode)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ranges">Page ranges</SelectItem>
<SelectItem value="interval">Every N pages</SelectItem>
<SelectItem value="size">Max file size (MB)</SelectItem>
</SelectContent>
</Select>
</div>
{splitMode === 'ranges' && (
<div className="space-y-1.5">
<Label>Page ranges (e.g. 1-3,5,7-10)</Label>
<Input
value={pageRanges}
onChange={(e) => setPageRanges(e.target.value)}
placeholder="1-3,5,7-10"
/>
</div>
)}
{splitMode === 'interval' && (
<div className="space-y-1.5">
<Label>Pages per split</Label>
<Input
type="number"
value={splitInterval}
onChange={(e) => setSplitInterval(e.target.value)}
placeholder="5"
/>
</div>
)}
{splitMode === 'size' && (
<div className="space-y-1.5">
<Label>Max file size (MB)</Label>
<Input
type="number"
value={splitSize}
onChange={(e) => setSplitSize(e.target.value)}
placeholder="1"
/>
</div>
)}
{renderError()}
</TabsContent>
<TabsContent value="compress" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Compression level: {compressLevel}%</Label>
<Slider
value={[compressLevel]}
onValueChange={(v) => setCompressLevel(v[0])}
min={0}
max={100}
step={5}
aria-label="Compression level"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Low quality / small size</span>
<span>High quality / large size</span>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2">
<Checkbox
checked={compressImages}
onCheckedChange={(c) => setCompressImages(!!c)}
/>
Compress images
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={compressDuplicates}
onCheckedChange={(c) => setCompressDuplicates(!!c)}
/>
Remove duplicate objects
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={compressFonts}
onCheckedChange={(c) => setCompressFonts(!!c)}
/>
Subset embedded fonts
</label>
</div>
{renderError()}
</TabsContent>
<TabsContent value="rotate" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Pages (e.g. 1,3,5-8 leave blank for all)</Label>
<Input
value={pages}
onChange={(e) => setPages(e.target.value)}
placeholder="All pages"
/>
</div>
<div className="space-y-1.5">
<Label>Rotation angle</Label>
<Select value={rotateAngle} onValueChange={setRotateAngle}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="90">90° clockwise</SelectItem>
<SelectItem value="180">180°</SelectItem>
<SelectItem value="270">270° clockwise</SelectItem>
</SelectContent>
</Select>
</div>
{renderError()}
</TabsContent>
<TabsContent value="delete" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Pages to delete (e.g. 1,3,5-8)</Label>
<Input
value={pages}
onChange={(e) => setPages(e.target.value)}
placeholder="1,3,5-8"
/>
</div>
{renderError()}
</TabsContent>
<TabsContent value="reorder" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>New page order (comma-separated, e.g. 3,1,2)</Label>
<Textarea
value={newOrder}
onChange={(e) => setNewOrder(e.target.value)}
placeholder="3,1,2,5,4"
rows={2}
/>
</div>
{renderError()}
</TabsContent>
<TabsContent value="watermark" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Watermark text</Label>
<Input
value={watermarkText}
onChange={(e) => setWatermarkText(e.target.value)}
placeholder="CONFIDENTIAL"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Font size</Label>
<Input
type="number"
value={watermarkFontSize}
onChange={(e) => setWatermarkFontSize(e.target.value)}
placeholder="48"
/>
</div>
<div className="space-y-1.5">
<Label>Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={watermarkColor}
onChange={(e) => setWatermarkColor(e.target.value)}
className="h-10 w-14 cursor-pointer p-1"
/>
<Input
value={watermarkColor}
onChange={(e) => setWatermarkColor(e.target.value)}
placeholder="#ff0000"
className="flex-1"
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label>Opacity: {watermarkOpacity}%</Label>
<Slider
value={[watermarkOpacity]}
onValueChange={(v) => setWatermarkOpacity(v[0])}
min={5}
max={100}
step={5}
aria-label="Watermark opacity"
/>
</div>
<div className="space-y-1.5">
<Label>Position</Label>
<Select value={watermarkPosition} onValueChange={setWatermarkPosition}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WATERMARK_POSITIONS.map((p) => (
<SelectItem key={p} value={p}>
{p.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Pages (leave blank for all)</Label>
<Input
value={watermarkPages}
onChange={(e) => setWatermarkPages(e.target.value)}
placeholder="All pages"
/>
</div>
{renderError()}
</TabsContent>
<TabsContent value="encrypt" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>User password</Label>
<Input
type="password"
value={userPassword}
onChange={(e) => setUserPassword(e.target.value)}
placeholder="Password to open"
/>
</div>
<div className="space-y-1.5">
<Label>Owner password</Label>
<Input
type="password"
value={ownerPassword}
onChange={(e) => setOwnerPassword(e.target.value)}
placeholder="Password for permissions"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Encryption level</Label>
<Select value={encryptionLevel} onValueChange={setEncryptionLevel}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="40">40-bit RC4</SelectItem>
<SelectItem value="128">128-bit RC4</SelectItem>
<SelectItem value="128-aes">128-bit AES</SelectItem>
<SelectItem value="256-aes">256-bit AES</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
{PERMISSION_OPTIONS.map((p) => (
<label key={p.key} className="flex items-center gap-2">
<Checkbox
checked={!!encryptPermissions[p.key]}
onCheckedChange={(c) =>
setEncryptPermissions((prev) => ({ ...prev, [p.key]: !!c }))
}
/>
{p.label}
</label>
))}
</div>
{renderError()}
</TabsContent>
<TabsContent value="decrypt" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Password</Label>
<Input
type="password"
value={decryptPassword}
onChange={(e) => setDecryptPassword(e.target.value)}
placeholder="Enter document password"
/>
</div>
{renderError()}
</TabsContent>
<TabsContent value="permissions" className="space-y-3 pt-3">
{renderFileField(
'Input PDF',
inputPath,
setInputPath,
handlePickInput,
'Select input PDF'
)}
{renderFileField(
'Output path',
outputPath,
setOutputPath,
handlePickOutput,
'output.pdf'
)}
<div className="space-y-1.5">
<Label>Owner password</Label>
<Input
type="password"
value={permissionsPassword}
onChange={(e) => setPermissionsPassword(e.target.value)}
placeholder="Enter owner password"
/>
</div>
<div className="flex flex-col gap-2">
{PERMISSION_OPTIONS.map((p) => (
<label key={p.key} className="flex items-center gap-2">
<Checkbox
checked={!!permissions[p.key]}
onCheckedChange={(c) => setPermissions((prev) => ({ ...prev, [p.key]: !!c }))}
/>
{p.label}
</label>
))}
</div>
{renderError()}
</TabsContent>
</div>
</Tabs>
{renderProgress()}
<div className="flex justify-end gap-2 border-t pt-3">
<Button variant="ghost" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Processing
</>
) : (
'Apply'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,386 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAppStore } from '@/stores/app-store';
import { ExportDialogFooter } from './ExportDialogFooter';
import { toast } from '@/lib/toast';
type ToolKey = 'imagemagick' | 'ffmpeg' | 'libreoffice' | 'pandoc';
interface FormatOption {
value: string;
label: string;
}
const converterFormats: Record<ToolKey, { input: FormatOption[]; output: FormatOption[] }> = {
imagemagick: {
input: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'tiff', label: 'TIFF' },
{ value: 'svg', label: 'SVG' },
{ value: 'ico', label: 'ICO' },
],
output: [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPEG' },
{ value: 'webp', label: 'WebP' },
{ value: 'gif', label: 'GIF' },
{ value: 'pdf', label: 'PDF' },
{ value: 'bmp', label: 'BMP' },
{ value: 'ico', label: 'ICO' },
],
},
ffmpeg: {
input: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'mov', label: 'MOV' },
],
output: [
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'wav', label: 'WAV' },
{ value: 'avi', label: 'AVI' },
{ value: 'mkv', label: 'MKV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'gif', label: 'GIF' },
{ value: 'webm', label: 'WebM' },
],
},
libreoffice: {
input: [
{ value: 'docx', label: 'DOCX' },
{ value: 'xlsx', label: 'XLSX' },
{ value: 'pptx', label: 'PPTX' },
{ value: 'odt', label: 'ODT' },
{ value: 'pdf', label: 'PDF' },
{ value: 'html', label: 'HTML' },
],
output: [
{ value: 'pdf', label: 'PDF' },
{ value: 'docx', label: 'DOCX' },
{ value: 'xlsx', label: 'XLSX' },
{ value: 'pptx', label: 'PPTX' },
{ value: 'odt', label: 'ODT' },
{ value: 'html', label: 'HTML' },
{ value: 'txt', label: 'Text' },
],
},
pandoc: {
input: [
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
{ value: 'docx', label: 'DOCX' },
{ value: 'latex', label: 'LaTeX' },
{ value: 'rst', label: 'reStructuredText' },
{ value: 'epub', label: 'EPUB' },
{ value: 'org', label: 'Org Mode' },
{ value: 'wiki', label: 'MediaWiki' },
],
output: [
{ value: 'pdf', label: 'PDF' },
{ value: 'docx', label: 'DOCX' },
{ value: 'html', label: 'HTML' },
{ value: 'latex', label: 'LaTeX' },
{ value: 'epub', label: 'EPUB' },
{ value: 'rst', label: 'reStructuredText' },
{ value: 'org', label: 'Org Mode' },
{ value: 'plain', label: 'Plain Text' },
{ value: 'rtf', label: 'RTF' },
{ value: 'odt', label: 'ODT' },
{ value: 'wiki', label: 'MediaWiki' },
],
},
};
const toolLabels: Record<ToolKey, string> = {
imagemagick: 'ImageMagick',
ffmpeg: 'FFmpeg',
libreoffice: 'LibreOffice',
pandoc: 'Pandoc',
};
export function UniversalConverterDialog() {
const closeModal = useAppStore((s) => s.closeModal);
const [tool, setTool] = useState<ToolKey>('pandoc');
const [fromFormat, setFromFormat] = useState('');
const [toFormat, setToFormat] = useState('');
const [filePath, setFilePath] = useState('');
const [batchMode, setBatchMode] = useState(false);
const [inputFolder, setInputFolder] = useState('');
const [outputFolder, setOutputFolder] = useState('');
const [converting, setConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputFormats = converterFormats[tool].input;
const outputFormats = converterFormats[tool].output;
useEffect(() => {
setFromFormat('');
setToFormat('');
}, [tool]);
const handleBrowseFile = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFile?.();
if (typeof result === 'string') {
setFilePath(result);
}
}, []);
const handleBrowseInputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setInputFolder(result);
}
}, []);
const handleBrowseOutputFolder = useCallback(async () => {
const result = await window.electronAPI?.file?.pickFolder?.();
if (typeof result === 'string') {
setOutputFolder(result);
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const handlers: Array<() => void> = [];
const statusHandler = (_event: unknown, data: { percent?: number; message?: string }) => {
if (typeof data?.percent === 'number') setProgress(data.percent);
};
const completeHandler = (_event: unknown, data: { outputPath?: string; error?: string }) => {
setConverting(false);
setProgress(100);
if (data?.error) {
setError(data.error);
toast.error(`Conversion failed: ${data.error}`);
} else {
toast.success('Conversion complete');
closeModal();
}
};
const batchProgressHandler = (_event: unknown, data: { current?: number; total?: number }) => {
if (typeof data?.current === 'number' && typeof data?.total === 'number') {
setProgress(Math.round((data.current / data.total) * 100));
}
};
const unsubStatus = window.electronAPI?.on?.('conversion-status', statusHandler) ?? (() => {});
const unsubComplete =
window.electronAPI?.on?.('conversion-complete', completeHandler) ?? (() => {});
const unsubBatch =
window.electronAPI?.on?.('batch-progress', batchProgressHandler) ?? (() => {});
handlers.push(unsubStatus, unsubComplete, unsubBatch);
return () => handlers.forEach((h) => h());
}, [closeModal]);
const handleConvert = async () => {
if (!fromFormat || !toFormat) {
setError('Select both source and target formats');
return;
}
if (!batchMode && !filePath) {
setError('Select a file to convert');
return;
}
if (batchMode && (!inputFolder || !outputFolder)) {
setError('Select both input and output folders');
return;
}
setConverting(true);
setProgress(0);
setError(null);
try {
if (batchMode) {
await window.electronAPI?.converter?.convertBatch?.({
tool,
fromFormat,
toFormat,
inputFolder,
outputFolder,
});
} else {
await window.electronAPI?.converter?.convert?.({
tool,
inputPath: filePath,
fromFormat,
toFormat,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
toast.error(`Conversion failed: ${msg}`);
setConverting(false);
}
};
return (
<Dialog open onOpenChange={(o) => !o && closeModal()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Universal Converter</DialogTitle>
<DialogDescription>
Convert between image, audio, video, document, and markup formats
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm">
{!batchMode && (
<div className="flex items-center gap-2">
<Input
value={filePath}
onChange={(e) => setFilePath(e.target.value)}
placeholder="Select a file..."
className="flex-1"
aria-label="File path"
/>
<Button variant="outline" onClick={handleBrowseFile}>
Browse
</Button>
</div>
)}
<div>
<Label htmlFor="converter-tool">Tool</Label>
<Select value={tool} onValueChange={(v) => setTool(v as ToolKey)}>
<SelectTrigger id="converter-tool" aria-label="Conversion tool">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(converterFormats) as ToolKey[]).map((key) => (
<SelectItem key={key} value={key}>
{toolLabels[key]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="converter-from">From</Label>
<Select value={fromFormat} onValueChange={setFromFormat}>
<SelectTrigger id="converter-from" aria-label="Source format">
<SelectValue placeholder="Source format" />
</SelectTrigger>
<SelectContent>
{inputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="converter-to">To</Label>
<Select value={toFormat} onValueChange={setToFormat}>
<SelectTrigger id="converter-to" aria-label="Target format">
<SelectValue placeholder="Target format" />
</SelectTrigger>
<SelectContent>
{outputFormats.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-3">
<Switch checked={batchMode} onCheckedChange={setBatchMode} id="batch-mode" />
<Label htmlFor="batch-mode">Batch mode</Label>
</div>
{batchMode && (
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2">
<Input
value={inputFolder}
onChange={(e) => setInputFolder(e.target.value)}
placeholder="Input folder"
className="flex-1"
aria-label="Input folder"
/>
<Button variant="outline" size="sm" onClick={handleBrowseInputFolder}>
...
</Button>
</div>
<div className="flex items-center gap-2">
<Input
value={outputFolder}
onChange={(e) => setOutputFolder(e.target.value)}
placeholder="Output folder"
className="flex-1"
aria-label="Output folder"
/>
<Button variant="outline" size="sm" onClick={handleBrowseOutputFolder}>
...
</Button>
</div>
</div>
)}
{converting && (
<div className="space-y-1">
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.max(2, progress)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground text-right">{progress}%</p>
</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={handleConvert}
submitting={converting}
submitLabel="Convert"
/>
</DialogContent>
</Dialog>
);
}
+270 -21
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,44 +11,186 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useCommandStore } from '@/stores/command-store';
import { cn } from '@/lib/utils';
interface QuickStartItem {
label: string;
shortcut: string;
commandId: string;
}
interface FeatureItem {
label: string;
description: string;
icon: string;
}
const quickStartItems: QuickStartItem[] = [
{ label: 'New File', shortcut: 'Ctrl+N', commandId: 'file.new' },
{ label: 'Open File', shortcut: 'Ctrl+O', commandId: 'file.open' },
{ label: 'Open Folder', shortcut: 'Ctrl+Shift+O', commandId: 'file.openFolder' },
{ label: 'Command Palette', shortcut: 'Ctrl+Shift+P', commandId: 'shortcuts.show' },
];
const features: FeatureItem[] = [
{ label: 'CodeMirror 6', description: 'Fast, extensible editor', icon: 'code' },
{ label: 'Live Preview', description: 'Mermaid, KaTeX support', icon: 'eye' },
{ label: 'PDF Editing', description: 'Print-ready export', icon: 'file-text' },
{ label: '25+ Themes', description: 'Light, dark, custom', icon: 'palette' },
{ label: 'Batch Conversion', description: 'ImageMagick, FFmpeg, more', icon: 'layers' },
{ label: 'Plugin System', description: 'Extend with plugins', icon: 'puzzle' },
];
const shortcutEntries = [
{ keys: 'Ctrl+S', action: 'Save' },
{ keys: 'Ctrl+B', action: 'Toggle sidebar' },
{ keys: 'Ctrl+\\', action: 'Toggle preview' },
{ keys: 'Ctrl+W', action: 'Close tab' },
{ keys: 'Ctrl+F', action: 'Find' },
{ keys: 'Ctrl+Shift+P', action: 'Command palette' },
{ keys: 'Ctrl+K Z', action: 'Zen mode' },
{ keys: 'Ctrl+Tab', action: 'Next tab' },
];
export function WelcomeDialog() { export function WelcomeDialog() {
const closeModal = useAppStore((s) => s.closeModal); const closeModal = useAppStore((s) => s.closeModal);
const setSetting = useSettingsStore((s) => s.setSetting); const setSetting = useSettingsStore((s) => s.setSetting);
const [dontShow, setDontShow] = useState(false); const [dontShow, setDontShow] = useState(false);
const [version, setVersion] = useState('');
const [recentFiles, setRecentFiles] = useState<string[]>([]);
useEffect(() => {
async function loadVersion() {
const result = await window.electronAPI?.app?.getVersion?.();
if (result && typeof result === 'object' && 'data' in result) {
setVersion((result as { data: string }).data);
} else if (typeof result === 'string') {
setVersion(result);
}
}
void loadVersion();
const stored = localStorage.getItem('mc-recent-files');
if (stored) {
try {
setRecentFiles(JSON.parse(stored).slice(0, 5));
} catch {
/* ignore */
}
}
}, []);
const handleClose = () => { const handleClose = () => {
if (dontShow) setSetting('welcomeDismissed', true); if (dontShow) setSetting('welcomeDismissed', true);
closeModal(); closeModal();
}; };
const handleQuickStart = (commandId: string) => {
useCommandStore.getState().dispatch(commandId);
handleClose();
};
return ( return (
<Dialog open onOpenChange={(o) => !o && handleClose()}> <Dialog open onOpenChange={(o) => !o && handleClose()}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Welcome to MarkdownConverter</DialogTitle> <DialogTitle className="text-xl">
Welcome to MarkdownConverter{version ? ` v${version}` : ''}
</DialogTitle>
<DialogDescription> <DialogDescription>
A polished editor for Markdown, with PDF, DOCX, and HTML export. A powerful Markdown editor with PDF, DOCX, HTML export and universal file conversion.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 text-sm"> <div className="grid gap-4 text-sm">
<div className="rounded-md border border-border bg-card/30 p-3"> <div className="grid grid-cols-3 gap-4">
<h3 className="font-semibold">1. Open a folder</h3> <div className="space-y-2">
<p className="text-muted-foreground"> <h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
File Open Folder, or O. Your tree appears on the left. Quick Start
</p> </h3>
{quickStartItems.map((item) => (
<button
key={item.commandId}
onClick={() => handleQuickStart(item.commandId)}
className="flex w-full items-center justify-between rounded-md border border-border bg-card/30 p-2.5 text-left transition-colors hover:bg-accent/50"
>
<span className="font-medium">{item.label}</span>
<kbd className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{item.shortcut}
</kbd>
</button>
))}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Features
</h3>
{features.map((feat) => (
<div
key={feat.label}
className="flex items-start gap-2 rounded-md border border-border bg-card/30 p-2.5"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-primary/10 text-primary">
<FeatureIcon name={feat.icon} />
</div>
<div>
<p className="font-medium leading-tight">{feat.label}</p>
<p className="text-xs text-muted-foreground">{feat.description}</p>
</div>
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Recent Files
</h3>
{recentFiles.length === 0 ? (
<div className="rounded-md border border-border bg-card/30 p-4 text-center text-muted-foreground">
No recent files
</div>
) : (
recentFiles.map((path, idx) => (
<button
key={`${path}-${idx}`}
onClick={() => {
useCommandStore.getState().dispatch('file.opened', { path });
handleClose();
}}
className="flex w-full items-center rounded-md border border-border bg-card/30 px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
>
<span className="flex-1 truncate text-xs" title={path}>
{path.split('/').pop()}
</span>
<span
className="ml-2 truncate font-mono text-[10px] text-muted-foreground"
title={path}
>
{path}
</span>
</button>
))
)}
<h3 className="pt-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Shortcuts
</h3>
<div className="rounded-md border border-border bg-card/30">
{shortcutEntries.map((s) => (
<div
key={s.keys}
className={cn(
'flex items-center justify-between px-2.5 py-1.5 text-xs',
shortcutEntries.indexOf(s) < shortcutEntries.length - 1 &&
'border-b border-border'
)}
>
<span className="text-muted-foreground">{s.action}</span>
<kbd className="font-mono text-[10px]">{s.keys}</kbd>
</div>
))}
</div> </div>
<div className="rounded-md border border-border bg-card/30 p-3">
<h3 className="font-semibold">2. Edit & preview</h3>
<p className="text-muted-foreground">
Type on the left, see the rendered preview on the right. Toggle with \.
</p>
</div> </div>
<div className="rounded-md border border-border bg-card/30 p-3">
<h3 className="font-semibold">3. Export anywhere</h3>
<p className="text-muted-foreground">
File Export to PDF / DOCX / HTML, or batch convert a folder.
</p>
</div> </div>
</div> </div>
<DialogFooter className="flex-row items-center justify-between sm:justify-between"> <DialogFooter className="flex-row items-center justify-between sm:justify-between">
@@ -58,7 +200,7 @@ export function WelcomeDialog() {
onCheckedChange={(c) => setDontShow(!!c)} onCheckedChange={(c) => setDontShow(!!c)}
aria-label="Don't show again" aria-label="Don't show again"
/> />
Don't show again Don't show on startup
</label> </label>
<Button onClick={handleClose}>Get started</Button> <Button onClick={handleClose}>Get started</Button>
</DialogFooter> </DialogFooter>
@@ -66,3 +208,110 @@ export function WelcomeDialog() {
</Dialog> </Dialog>
); );
} }
function FeatureIcon({ name }: { name: string }) {
const icons: Record<string, JSX.Element> = {
code: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
),
eye: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
'file-text': (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
),
palette: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</svg>
),
layers: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z" />
<path d="m22 12.5-8.58 3.91a2 2 0 0 1-1.66 0L2 12.08" />
</svg>
),
puzzle: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.618 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
),
};
return icons[name] ?? null;
}
@@ -1,8 +1,20 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RefreshCw, FileX, FilePlus, FileEdit, FileQuestion } from 'lucide-react'; import {
RefreshCw,
FileX,
FilePlus,
FileEdit,
FileQuestion,
GitCommitHorizontal,
CheckSquare,
Square,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useFileStore } from '@/stores/file-store'; import { useFileStore } from '@/stores/file-store';
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
import { toast } from '@/lib/toast';
import { cn } from '@/lib/utils';
interface GitStatus { interface GitStatus {
filePath: string; filePath: string;
@@ -29,6 +41,10 @@ export function GitStatusPanel() {
const [status, setStatus] = useState<GitStatus[]>([]); const [status, setStatus] = useState<GitStatus[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [commitMsg, setCommitMsg] = useState('');
const [committing, setCommitting] = useState(false);
const [staging, setStaging] = useState(false);
const load = async () => { const load = async () => {
if (!rootPath) { if (!rootPath) {
@@ -50,12 +66,59 @@ export function GitStatusPanel() {
useEffect(() => { useEffect(() => {
load(); load();
// Listen for git.refresh command via custom event
const handler = () => load(); const handler = () => load();
window.addEventListener('mc:git-refresh', handler); window.addEventListener('mc:git-refresh', handler);
return () => window.removeEventListener('mc:git-refresh', handler); return () => window.removeEventListener('mc:git-refresh', handler);
}, [rootPath]); }, [rootPath]);
const toggleSelect = (filePath: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(filePath)) next.delete(filePath);
else next.add(filePath);
return next;
});
};
const toggleSelectAll = () => {
if (selected.size === status.length) {
setSelected(new Set());
} else {
setSelected(new Set(status.map((s) => s.filePath)));
}
};
const stageFiles = async (files: string[]) => {
if (!rootPath || files.length === 0) return;
setStaging(true);
try {
await window.electronAPI.gitStage(files);
toast.success(`Staged ${files.length} file${files.length === 1 ? '' : 's'}`);
setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch {
toast.error('Failed to stage files');
} finally {
setStaging(false);
}
};
const commit = async () => {
if (!rootPath || !commitMsg.trim()) return;
setCommitting(true);
try {
await window.electronAPI.gitCommit(commitMsg.trim());
toast.success('Changes committed');
setCommitMsg('');
setSelected(new Set());
window.dispatchEvent(new CustomEvent('mc:git-refresh'));
} catch {
toast.error('Failed to commit changes');
} finally {
setCommitting(false);
}
};
if (!rootPath) { if (!rootPath) {
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>; return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
} }
@@ -77,7 +140,7 @@ export function GitStatusPanel() {
} }
return ( return (
<div className="p-2 text-xs"> <div className="flex flex-col p-2 text-xs">
<div className="flex items-center justify-between px-1 py-1"> <div className="flex items-center justify-between px-1 py-1">
<span className="font-semibold"> <span className="font-semibold">
{status.length} changed file{status.length === 1 ? '' : 's'} {status.length} changed file{status.length === 1 ? '' : 's'}
@@ -86,19 +149,96 @@ export function GitStatusPanel() {
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className="space-y-0.5">
{status.map((s) => ( <div className="mb-1 flex gap-1 px-1">
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
onClick={toggleSelectAll}
data-testid="git-select-all"
>
{selected.size === status.length ? 'Deselect All' : 'Select All'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={selected.size === 0 || staging}
onClick={() => stageFiles(Array.from(selected))}
data-testid="git-stage-selected"
>
Stage Selected
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-[10px]"
disabled={staging}
onClick={() => stageFiles(status.map((s) => s.filePath))}
data-testid="git-stage-all"
>
Stage All
</Button>
</div>
<div className="flex-1 space-y-0.5 overflow-y-auto">
{status.map((s) => {
const isSelected = selected.has(s.filePath);
return (
<button <button
key={s.filePath} key={s.filePath}
onClick={() => openFile(s.filePath)} onClick={() => openFile(s.filePath)}
className="flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50" className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1 text-left hover:bg-card/50',
isSelected && 'bg-accent/40'
)}
data-testid="git-status-row" data-testid="git-status-row"
> >
<button
onClick={(e) => {
e.stopPropagation();
toggleSelect(s.filePath);
}}
className="flex-shrink-0"
aria-label={isSelected ? 'Deselect' : 'Select'}
data-testid="git-status-checkbox"
>
{isSelected ? (
<CheckSquare className="h-3 w-3 text-primary" />
) : (
<Square className="h-3 w-3 text-muted-foreground" />
)}
</button>
{STATUS_ICON[s.status]} {STATUS_ICON[s.status]}
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span> <span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span> <span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
</button> </button>
))} );
})}
</div>
<div className="mt-2 space-y-1.5 border-t border-border pt-2">
<Input
placeholder="Commit message..."
className="h-7 text-xs"
value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
data-testid="git-commit-input"
/>
<Button
size="sm"
className="h-7 w-full text-xs"
disabled={!commitMsg.trim() || committing}
onClick={commit}
data-testid="git-commit-button"
>
<GitCommitHorizontal className="h-3 w-3" />
Commit
</Button>
</div> </div>
</div> </div>
); );
@@ -51,23 +51,7 @@ export function registerMenuCommands(): void {
'help.welcome': () => useAppStore.getState().openModal('welcome'), 'help.welcome': () => useAppStore.getState().openModal('welcome'),
'shortcuts.show': () => { 'shortcuts.show': () => {
const open: OpenModal = useAppStore.getState().openModal; const open: OpenModal = useAppStore.getState().openModal;
open('confirm', { window.dispatchEvent(new CustomEvent('mc:command-palette-toggle'));
title: 'Keyboard shortcuts',
body: [
'Open file — ⌘/Ctrl+O',
'Open folder — ⌘/Ctrl+Shift+O',
'Save — ⌘/Ctrl+S',
'Close tab — ⌘/Ctrl+W',
'Next/prev tab — ⌘/Ctrl+Tab / +Shift+Tab',
'Toggle sidebar — ⌘/Ctrl+B',
'Toggle preview — ⌘/Ctrl+Shift+P',
'Zen mode — ⌘/Ctrl+K then Z',
'Find — ⌘/Ctrl+F',
].join('\n'),
confirmLabel: 'Got it',
cancelLabel: 'Close',
onConfirm: () => undefined,
});
}, },
'file.exportPdf': () => { 'file.exportPdf': () => {
const activeTabId = useFileStore.getState().activeTabId; const activeTabId = useFileStore.getState().activeTabId;
@@ -259,10 +243,20 @@ export function registerMenuCommands(): void {
'tools.documentCompare': () => { 'tools.documentCompare': () => {
toast.info('Document compare — coming soon!'); toast.info('Document compare — coming soon!');
}, },
'tools.pdfEditor': () => useAppStore.getState().openModal('pdf-editor'),
// Header & footer settings — point users to Settings → Editor. // Header & footer settings — dedicated dialog.
'settings.headerFooter': () => { 'settings.headerFooter': () => {
useAppStore.getState().openModal('settings'); useAppStore.getState().openModal('header-footer');
},
// Universal converter — single file or batch.
'tools.universalConverter': () => {
useAppStore.getState().openModal('universal-converter');
},
'tools.batchMediaConverter': () => {
useAppStore.getState().openModal('batch-media-converter');
}, },
'file.clearRecent': () => { 'file.clearRecent': () => {
+8
View File
@@ -95,6 +95,14 @@ export const ipc = {
path: string; path: string;
buffer: Uint8Array; buffer: Uint8Array;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'writeBuffer', args), }): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'writeBuffer', args),
gitStage: (args: {
rootPath: string;
files: string[];
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitStage', args),
gitCommit: (args: {
rootPath: string;
message: string;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('file', 'gitCommit', args),
setCurrent: (path: string | null): void => { setCurrent: (path: string | null): void => {
if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) { if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) {
(window.electronAPI as any).file.setCurrent(path); (window.electronAPI as any).file.setCurrent(path);
+9 -1
View File
@@ -32,7 +32,11 @@ export type ModalState =
| { kind: 'table-generator' } | { kind: 'table-generator' }
| { kind: 'find-in-files' } | { kind: 'find-in-files' }
| { kind: 'crashReports' } | { kind: 'crashReports' }
| { kind: 'writing-analytics' }; | { kind: 'writing-analytics' }
| { kind: 'pdf-editor'; props?: { filePath?: string } }
| { kind: 'universal-converter' }
| { kind: 'header-footer' }
| { kind: 'batch-media-converter' };
export type ModalKind = ModalState['kind']; export type ModalKind = ModalState['kind'];
@@ -43,10 +47,12 @@ interface AppState {
paneSizes: PaneSizes; paneSizes: PaneSizes;
modal: ModalState; modal: ModalState;
firstRun: boolean; firstRun: boolean;
findBarOpen: boolean;
toggleSidebar: () => void; toggleSidebar: () => void;
togglePreview: () => void; togglePreview: () => void;
setZenMode: (value: boolean) => void; setZenMode: (value: boolean) => void;
setPaneSizes: (sizes: PaneSizes) => void; setPaneSizes: (sizes: PaneSizes) => void;
toggleFindBar: () => void;
openModal: <K extends NonNullable<ModalKind>>( openModal: <K extends NonNullable<ModalKind>>(
kind: K, kind: K,
...args: Extract<ModalState, { kind: K }> extends { props: infer P } ? [props?: P] : [] ...args: Extract<ModalState, { kind: K }> extends { props: infer P } ? [props?: P] : []
@@ -65,8 +71,10 @@ export const useAppStore = create<AppState>()(
paneSizes: { sidebar: 20, editor: 50, preview: 30 }, paneSizes: { sidebar: 20, editor: 50, preview: 30 },
modal: { kind: null }, modal: { kind: null },
firstRun: true, firstRun: true,
findBarOpen: false,
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })), toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })), togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
toggleFindBar: () => set((s) => ({ findBarOpen: !s.findBarOpen })),
setZenMode: (value) => set({ zenMode: value }), setZenMode: (value) => set({ zenMode: value }),
setPaneSizes: (sizes) => set({ paneSizes: sizes }), setPaneSizes: (sizes) => set({ paneSizes: sizes }),
openModal: (kind, ...args) => openModal: (kind, ...args) =>
+1 -3
View File
@@ -1,5 +1,3 @@
const path = require('path');
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) { function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
container.innerHTML = ` container.innerHTML = `
<div class="explorer-panel"> <div class="explorer-panel">
@@ -39,7 +37,7 @@ function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir
} }
} }
function renderTree(container, entries, listDirectory, onFileOpen, basePath) { function renderTree(container, entries, listDirectory, onFileOpen, _basePath) {
container.innerHTML = entries container.innerHTML = entries
.map((entry) => { .map((entry) => {
if (entry.isDirectory) { if (entry.isDirectory) {
+1 -1
View File
@@ -1,4 +1,4 @@
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) { function renderGitPanel(container, { gitStatus, gitDiff: _gitDiff, gitStage, gitCommit, gitLog }) {
container.innerHTML = ` container.innerHTML = `
<div class="git-panel"> <div class="git-panel">
<div class="git-section"> <div class="git-section">
-3
View File
@@ -1,6 +1,3 @@
const fs = require('fs');
const path = require('path');
const templates = [ const templates = [
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' }, { name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
{ name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' }, { name: 'Meeting Notes', file: 'meeting-notes.md', description: 'Agenda, notes, action items' },
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BatchMediaConverterDialog } from '@/components/modals/BatchMediaConverterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const mockPickFolder = vi.fn().mockResolvedValue('/some/folder');
const mockOn = vi.fn().mockReturnValue(() => {});
const mockConvertBatch = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/ipc', () => ({
ipc: { file: { pickFolder: mockPickFolder } },
}));
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mockPickFolder.mockClear();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
file: {
pickFolder: mockPickFolder,
},
on: mockOn,
converter: { convertBatch: mockConvertBatch },
};
});
describe('BatchMediaConverterDialog', () => {
it('renders with ImageMagick and FFmpeg tabs', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByText(/batch media converter/i)).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'ImageMagick' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'FFmpeg' })).toBeInTheDocument();
});
it('shows format dropdowns for the active tool', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByRole('combobox', { name: /source format/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /target format/i })).toBeInTheDocument();
});
it('validates both formats are selected', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both source and target formats/i)).toBeInTheDocument();
});
it('validates folders are selected', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'JPEG' }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both input and output folders/i)).toBeInTheDocument();
});
it('browse buttons set folder paths', async () => {
render(<BatchMediaConverterDialog />);
const browseButtons = screen.getAllByRole('button', { name: /^browse$/i });
await userEvent.click(browseButtons[0]);
await waitFor(() => expect(screen.getByLabelText(/input folder/i)).toHaveValue('/some/folder'));
await userEvent.click(browseButtons[1]);
await waitFor(() =>
expect(screen.getByLabelText(/output folder/i)).toHaveValue('/some/folder')
);
});
it('switches to FFmpeg tab and resets formats', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('tab', { name: 'FFmpeg' }));
expect(screen.getByRole('tab', { name: 'FFmpeg' })).toHaveAttribute('data-state', 'active');
});
it('shows include subdirectories toggle', () => {
render(<BatchMediaConverterDialog />);
expect(screen.getByRole('switch', { name: /include subdirectories/i })).toBeInTheDocument();
});
it('subscribes to batch-progress and conversion-complete IPC events', () => {
render(<BatchMediaConverterDialog />);
expect(mockOn).toHaveBeenCalledWith('batch-progress', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('conversion-complete', expect.any(Function));
});
it('submits with includeSubfolders when enabled', async () => {
render(<BatchMediaConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PNG' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'JPEG' }));
const browseButtons = screen.getAllByRole('button', { name: /^browse$/i });
await userEvent.click(browseButtons[0]);
await waitFor(() => expect(screen.getByLabelText(/input folder/i)).toHaveValue('/some/folder'));
await userEvent.click(browseButtons[1]);
await waitFor(() =>
expect(screen.getByLabelText(/output folder/i)).toHaveValue('/some/folder')
);
await userEvent.click(screen.getByRole('switch', { name: /include subdirectories/i }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
await waitFor(() => expect(mockConvertBatch).toHaveBeenCalledTimes(1));
const callArg = mockConvertBatch.mock.calls[0][0];
expect(callArg.tool).toBe('imagemagick');
expect(callArg.fromFormat).toBe('png');
expect(callArg.toFormat).toBe('jpg');
expect(callArg.includeSubfolders).toBe(true);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CommandPalette } from '@/components/modals/CommandPalette';
import { useCommandStore } from '@/stores/command-store';
beforeEach(() => {
localStorage.clear();
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useCommandStore.getState().register('file.open', vi.fn());
useCommandStore.getState().register('file.save', vi.fn());
useCommandStore.getState().register('file.new', vi.fn());
useCommandStore.getState().register('file.exportPdf', vi.fn());
useCommandStore.getState().register('settings.open', vi.fn());
});
describe('CommandPalette', () => {
it('does not render when closed', () => {
render(<CommandPalette />);
expect(screen.queryByPlaceholderText(/type a command/i)).not.toBeInTheDocument();
});
it('opens on Ctrl+Shift+P', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument();
});
it('closes on Escape', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
await waitFor(() => expect(screen.getByPlaceholderText(/type a command/i)).toBeInTheDocument());
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, '{Escape}');
expect(screen.queryByPlaceholderText(/type a command/i)).not.toBeInTheDocument();
});
it('filters commands based on query', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'open');
const items = screen.getAllByRole('option');
expect(items.length).toBeGreaterThan(0);
items.forEach((item) => {
expect(item.textContent?.toLowerCase()).toContain('open');
});
});
it('navigates with arrow keys', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, '{ArrowDown}{ArrowDown}');
const items = screen.getAllByRole('option');
const selected = items.find((el) => el.getAttribute('aria-selected') === 'true');
expect(selected).toBe(items[2]);
});
it('executes command on Enter', async () => {
const handler = vi.fn();
useCommandStore.getState().register('test.command', handler);
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'test');
await userEvent.keyboard('{Enter}');
expect(handler).toHaveBeenCalled();
});
it('shows no results message for non-matching query', async () => {
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
const input = screen.getByPlaceholderText(/type a command/i);
await userEvent.type(input, 'zzznonexistent');
expect(screen.getByText(/no matching commands/i)).toBeInTheDocument();
});
it('limits results to 8', async () => {
for (let i = 0; i < 20; i++) {
useCommandStore.getState().register(`test.command.${i}`, vi.fn());
}
render(<CommandPalette />);
await userEvent.keyboard('{Control>}{Shift>}{p}{/Shift}{/Control}');
await waitFor(() => {
const items = screen.queryAllByRole('option');
expect(items.length).toBeLessThanOrEqual(8);
});
});
});
@@ -8,9 +8,7 @@ describe('ExportBatchDialog', () => {
localStorage.clear(); localStorage.clear();
window.electronAPI = { window.electronAPI = {
export: { export: {
batch: vi batch: vi.fn().mockResolvedValue({
.fn()
.mockResolvedValue({
ok: true, ok: true,
data: { total: 2, succeeded: 2, failed: 0, results: [] }, data: { total: 2, succeeded: 2, failed: 0, results: [] },
}), }),
@@ -14,9 +14,7 @@ vi.mock('@/lib/ipc', () => ({
})); }));
vi.mock('@/lib/docx-export', () => ({ vi.mock('@/lib/docx-export', () => ({
generateDocx: vi generateDocx: vi.fn().mockResolvedValue(
.fn()
.mockResolvedValue(
new Blob([new Uint8Array(8)], { new Blob([new Uint8Array(8)], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}) })
@@ -25,12 +23,10 @@ vi.mock('@/lib/docx-export', () => ({
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
describe('ExportDocxDialog', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
vi.clearAllMocks(); vi.clearAllMocks();
// The dialog passes the source path with .docx extension as the default (window.electronAPI as any) = {};
// path, and the test mock returns that same path (echoing the default).
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({ (ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true, ok: true,
data: args?.defaultPath ?? '/out.docx', data: args?.defaultPath ?? '/out.docx',
@@ -48,12 +44,35 @@ describe('ExportDocxDialog', () => {
} as any); } as any);
}); });
describe('ExportDocxDialog', () => {
it('renders with standard template selected by default', () => { it('renders with standard template selected by default', () => {
render(<ExportDocxDialog sourcePath="/test.md" />); render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to docx/i)).toBeInTheDocument(); expect(screen.getByText(/export to docx/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /template/i })).toBeInTheDocument();
}); });
it('shows reference doc input', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/reference document path/i)).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth when TOC is enabled', async () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows bibliography input', () => {
render(<ExportDocxDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/bibliography file path/i)).toBeInTheDocument();
});
it('submitting with default options writes a docx buffer', async () => { it('submitting with default options writes a docx buffer', async () => {
render(<ExportDocxDialog sourcePath="/test.md" />); render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i })); await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
@@ -71,4 +90,16 @@ describe('ExportDocxDialog', () => {
await userEvent.click(screen.getByRole('option', { name: /modern/i })); await userEvent.click(screen.getByRole('option', { name: /modern/i }));
expect(screen.getByRole('combobox', { name: /template/i })).toHaveTextContent(/modern/i); expect(screen.getByRole('combobox', { name: /template/i })).toHaveTextContent(/modern/i);
}); });
it('sends options via electronAPI.export.withOptions when available', async () => {
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportDocxDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('docx', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].toc).toBe(false);
expect(mockWithOptions.mock.calls[0][1].numberSections).toBe(false);
});
}); });
@@ -15,10 +15,10 @@ vi.mock('@/lib/ipc', () => ({
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
describe('ExportHtmlDialog', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
vi.clearAllMocks(); vi.clearAllMocks();
(window.electronAPI as any) = {};
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({ (ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
ok: true, ok: true,
data: args?.defaultPath ?? '/out.html', data: args?.defaultPath ?? '/out.html',
@@ -36,6 +36,7 @@ describe('ExportHtmlDialog', () => {
} as any); } as any);
}); });
describe('ExportHtmlDialog', () => {
it('renders with default github highlight style', () => { it('renders with default github highlight style', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />); render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to html/i)).toBeInTheDocument(); expect(screen.getByText(/export to html/i)).toBeInTheDocument();
@@ -49,9 +50,41 @@ describe('ExportHtmlDialog', () => {
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1); expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0]; const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0];
expect(callArg.path).toBe('/test.html'); expect(callArg.path).toBe('/test.html');
// The buffer is a Uint8Array; in JSDOM cross-realm the prototype check
// is unreliable, so verify shape instead.
expect(callArg.buffer).toBeDefined(); expect(callArg.buffer).toBeDefined();
expect(callArg.buffer.length).toBeGreaterThan(0); expect(callArg.buffer.length).toBeGreaterThan(0);
}); });
it('shows self-contained toggle', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /self-contained/i })).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth when TOC is enabled', async () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows custom CSS input', () => {
render(<ExportHtmlDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/custom css file path/i)).toBeInTheDocument();
});
it('sends options via electronAPI.export.withOptions when available', async () => {
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportHtmlDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('html', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].standalone).toBe(true);
expect(mockWithOptions.mock.calls[0][1].selfContained).toBe(false);
});
}); });
@@ -17,10 +17,10 @@ vi.mock('@/lib/ipc', () => ({
import { ipc } from '@/lib/ipc'; import { ipc } from '@/lib/ipc';
describe('ExportPdfDialog', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
vi.clearAllMocks(); vi.clearAllMocks();
delete (window as any).electronAPI;
useSettingsStore.setState(useSettingsStore.getInitialState()); useSettingsStore.setState(useSettingsStore.getInitialState());
useFileStore.setState({ useFileStore.setState({
activeTabId: '/test.md', activeTabId: '/test.md',
@@ -33,12 +33,47 @@ describe('ExportPdfDialog', () => {
} as any); } as any);
}); });
describe('ExportPdfDialog', () => {
it('renders with default PDF options from settings', () => { it('renders with default PDF options from settings', () => {
render(<ExportPdfDialog sourcePath="/test.md" />); render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByText(/export to pdf/i)).toBeInTheDocument(); expect(screen.getByText(/export to pdf/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /format/i })).toBeInTheDocument();
}); });
it('shows PDF engine selector', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /pdf engine/i })).toBeInTheDocument();
});
it('shows TOC toggle and number sections toggle', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('switch', { name: /table of contents/i })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: /number sections/i })).toBeInTheDocument();
});
it('shows TOC depth input when TOC is enabled', async () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('switch', { name: /table of contents/i }));
expect(screen.getByLabelText(/toc depth/i)).toBeInTheDocument();
});
it('shows page geometry dropdown', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /page geometry/i })).toBeInTheDocument();
});
it('shows bibliography and font inputs', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByLabelText(/bibliography file path/i)).toBeInTheDocument();
expect(screen.getByLabelText(/main font/i)).toBeInTheDocument();
expect(screen.getByLabelText(/cjk font/i)).toBeInTheDocument();
});
it('shows highlight style selector', () => {
render(<ExportPdfDialog sourcePath="/test.md" />);
expect(screen.getByRole('combobox', { name: /highlight style/i })).toBeInTheDocument();
});
it('toggles ASCII tables and submits via ipc.print.show', async () => { it('toggles ASCII tables and submits via ipc.print.show', async () => {
render(<ExportPdfDialog sourcePath="/test.md" />); render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i })); await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
@@ -46,16 +81,30 @@ describe('ExportPdfDialog', () => {
await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1)); await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1));
const [arg] = (ipc.print.show as any).mock.calls[0]; const [arg] = (ipc.print.show as any).mock.calls[0];
expect(arg.html).toContain('<!DOCTYPE html>'); expect(arg.html).toContain('<!DOCTYPE html>');
// The Markdown source is rendered to HTML inside the document body.
expect(arg.html).toContain('<h1>hi</h1>'); expect(arg.html).toContain('<h1>hi</h1>');
// @page CSS for size + margins must be present
expect(arg.html).toMatch(/@page\s*\{\s*size:\s*210mm\s+297mm/); expect(arg.html).toMatch(/@page\s*\{\s*size:\s*210mm\s+297mm/);
}); });
it('renders an error banner when IPC fails', async () => { it('uses electronAPI.export.withOptions when available', async () => {
(ipc.print.show as any).mockRejectedValueOnce(new Error('Pandoc not found')); const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
(window.electronAPI as any) = {
export: { withOptions: mockWithOptions },
};
render(<ExportPdfDialog sourcePath="/test.md" />); render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i })); await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
expect(await screen.findByText(/pandoc not found/i)).toBeInTheDocument(); await waitFor(() => expect(mockWithOptions).toHaveBeenCalledWith('pdf', expect.any(Object)));
expect(mockWithOptions.mock.calls[0][1].engine).toBe('pdflatex');
expect(mockWithOptions.mock.calls[0][1].toc).toBe(false);
expect(mockWithOptions.mock.calls[0][1].highlightStyle).toBe('tango');
});
it('renders an error banner when IPC fails', async () => {
(window as any).electronAPI = undefined;
(ipc.print.show as any).mockReturnValueOnce({ ok: false, error: { message: 'Pandoc not found' } });
render(<ExportPdfDialog sourcePath="/test.md" />);
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/pandoc not found/i);
});
}); });
}); });
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HeaderFooterDialog } from '@/components/modals/HeaderFooterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const defaultSettings = {
headerEnabled: false,
footerEnabled: false,
headerLeft: '',
headerCenter: '',
headerRight: '',
footerLeft: '',
footerCenter: '',
footerRight: '',
logoPosition: 'none',
logoPath: null,
};
const mockGetSettings = vi.fn().mockResolvedValue(defaultSettings);
const mockSaveSettings = vi.fn().mockResolvedValue(undefined);
const mockBrowseLogo = vi.fn().mockResolvedValue('/path/to/logo.png');
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
headerFooter: {
getSettings: mockGetSettings,
saveSettings: mockSaveSettings,
browseLogo: mockBrowseLogo,
},
};
});
describe('HeaderFooterDialog', () => {
it('loads settings from electronAPI on mount', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(mockGetSettings).toHaveBeenCalled());
expect(screen.getByText(/header & footer/i)).toBeInTheDocument();
});
it('toggles header enabled checkbox', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('checkbox', { name: /enable header/i }));
expect(screen.getByRole('checkbox', { name: /enable header/i })).toBeChecked();
});
it('toggles footer enabled checkbox', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('checkbox', { name: /enable footer/i }));
expect(screen.getByRole('checkbox', { name: /enable footer/i })).toBeChecked();
});
it('saves settings via electronAPI on Save', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(mockSaveSettings).toHaveBeenCalled());
});
it('closes modal on Cancel', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
await userEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
expect(useAppStore.getState().modal).toEqual({ kind: null });
});
it('inserts dynamic field token into header input', async () => {
mockGetSettings.mockResolvedValue({ ...defaultSettings, headerEnabled: true });
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const tokenButton = screen.getAllByTitle('Page')[0];
await userEvent.click(tokenButton);
expect(screen.getByLabelText(/header left/i)).toHaveValue('$PAGE$');
});
it('shows logo browse when logo position is set to left', async () => {
render(<HeaderFooterDialog />);
await waitFor(() => expect(screen.getByText(/header & footer/i)).toBeInTheDocument());
const logoSelect = screen.getByRole('combobox', { name: /logo position/i });
await userEvent.click(logoSelect);
await userEvent.click(screen.getByRole('option', { name: 'Left' }));
expect(screen.getByRole('button', { name: /^browse$/i })).toBeInTheDocument();
});
});
@@ -29,4 +29,10 @@ describe('ModalLayer', () => {
expect(screen.queryByText(/about markdownconverter/i)).not.toBeInTheDocument(); expect(screen.queryByText(/about markdownconverter/i)).not.toBeInTheDocument();
expect(screen.getByText(/settings/i)).toBeInTheDocument(); expect(screen.getByText(/settings/i)).toBeInTheDocument();
}); });
it('renders BatchMediaConverterDialog when kind is "batch-media-converter"', () => {
useAppStore.getState().openModal('batch-media-converter');
render(<ModalLayer />);
expect(screen.getByText(/batch media converter/i)).toBeInTheDocument();
});
}); });
@@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PdfEditorDialog } from '@/components/modals/PdfEditorDialog';
const mockProcessOperation = vi.fn().mockResolvedValue(undefined);
const mockPickFile = vi.fn();
const mockPickFolder = vi.fn();
const mockShowSaveDialog = vi.fn();
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
mockPickFile.mockResolvedValue({ ok: true, data: null });
mockPickFolder.mockResolvedValue({ ok: true, data: null });
mockShowSaveDialog.mockResolvedValue({ ok: true, data: null });
window.electronAPI = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
},
app: {
showSaveDialog: mockShowSaveDialog,
},
pdf: {
processOperation: mockProcessOperation,
},
} as any;
});
function dispatchComplete(success: boolean, error?: string) {
window.dispatchEvent(
new CustomEvent('pdf-operation-complete', {
detail: { success, error, message: success ? 'Done' : error },
})
);
}
describe('PdfEditorDialog', () => {
it('renders all operation tabs', () => {
render(<PdfEditorDialog onClose={() => {}} />);
expect(screen.getByText('Merge')).toBeInTheDocument();
expect(screen.getByText('Split')).toBeInTheDocument();
expect(screen.getByText('Compress')).toBeInTheDocument();
expect(screen.getByText('Rotate')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Reorder')).toBeInTheDocument();
expect(screen.getByText('Watermark')).toBeInTheDocument();
expect(screen.getByText('Encrypt')).toBeInTheDocument();
expect(screen.getByText('Decrypt')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
});
it('defaults to merge tab and shows file list', () => {
render(<PdfEditorDialog onClose={() => {}} />);
expect(screen.getByText('PDF files')).toBeInTheDocument();
expect(screen.getByText('Output path')).toBeInTheDocument();
});
it('pre-fills input path from initialFilePath', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
await userEvent.click(screen.getByText('Split'));
await waitFor(() => {
const inputs = screen.getAllByRole('textbox');
const match = inputs.find((el) => (el as HTMLInputElement).value === '/test.pdf');
expect(match).toBeTruthy();
});
});
it('validates merge requires at least 2 files', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/add at least 2 pdf files/i)).toBeInTheDocument();
});
it('validates input path for operations that need it', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByText('Compress'));
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/select an input pdf/i)).toBeInTheDocument();
});
it('validates watermark text is required', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
mockShowSaveDialog.mockResolvedValue({ ok: true, data: '/out.pdf' });
await userEvent.click(screen.getByText('Watermark'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/enter watermark text/i)).toBeInTheDocument());
});
it('validates decrypt password is required', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/test.pdf" />);
mockShowSaveDialog.mockResolvedValue({ ok: true, data: '/out.pdf' });
await userEvent.click(screen.getByText('Decrypt'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/enter the password/i)).toBeInTheDocument());
});
it('calls onClose on cancel click', () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} />);
const cancelBtn = screen.getAllByRole('button').find((b) => b.textContent === 'Cancel');
expect(cancelBtn).toBeDefined();
fireEvent.click(cancelBtn!);
expect(onClose).toHaveBeenCalled();
});
it('merge operation sends correct payload and shows success', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} />);
mockPickFile
.mockResolvedValueOnce({ ok: true, data: '/a.pdf' })
.mockResolvedValueOnce({ ok: true, data: '/b.pdf' });
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
expect(screen.getByText('/a.pdf')).toBeInTheDocument();
expect(screen.getByText('/b.pdf')).toBeInTheDocument();
const outputInput = screen.getByPlaceholderText('output.pdf');
await userEvent.type(outputInput, '/merged.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'merge',
inputPaths: ['/a.pdf', '/b.pdf'],
outputPath: '/merged.pdf',
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('rotate operation sends angle and pages', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Rotate'));
const pagesInput = screen.getAllByPlaceholderText('All pages')[0];
await userEvent.type(pagesInput, '1,3-5');
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'rotate',
inputPath: '/in.pdf',
pages: '1,3-5',
angle: 90,
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('delete operation sends pages', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Delete'));
const pagesInput = screen.getByPlaceholderText('1,3,5-8');
await userEvent.type(pagesInput, '2,4');
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({ operation: 'delete', inputPath: '/in.pdf', pages: '2,4' })
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('encrypt operation sends passwords and permissions', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Encrypt'));
await userEvent.type(screen.getByPlaceholderText('Password to open'), 'user123');
await userEvent.type(screen.getByPlaceholderText('Password for permissions'), 'owner123');
await userEvent.click(screen.getByRole('checkbox', { name: /printing/i }));
await userEvent.click(screen.getByRole('checkbox', { name: /copying/i }));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/encrypted.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'encrypt',
inputPath: '/in.pdf',
userPassword: 'user123',
ownerPassword: 'owner123',
permissions: { printing: true, copying: true },
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('split with interval mode sends correct payload', async () => {
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
mockPickFolder.mockResolvedValue({ ok: true, data: '/splits' });
await userEvent.click(screen.getByText('Split'));
const folderInputs = screen.getAllByPlaceholderText('Select output folder');
await userEvent.type(folderInputs[0], '/splits');
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByRole('option', { name: /every n pages/i }));
const intervalInputs = screen.getAllByPlaceholderText('5');
if (intervalInputs.length > 0) {
await userEvent.clear(intervalInputs[0]);
await userEvent.type(intervalInputs[0], '10');
}
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(mockProcessOperation).toHaveBeenCalledWith(
expect.objectContaining({
operation: 'split',
inputPath: '/in.pdf',
outputFolder: '/splits',
mode: 'interval',
interval: 10,
})
);
dispatchComplete(true);
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it('shows error toast on operation failure', async () => {
render(<PdfEditorDialog onClose={() => {}} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Compress'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
dispatchComplete(false, 'Compression failed');
await waitFor(() => expect(screen.getByText(/compression failed/i)).toBeInTheDocument());
});
it('shows error when IPC throws', async () => {
mockProcessOperation.mockRejectedValueOnce(new Error('IPC channel missing'));
const onClose = vi.fn();
render(<PdfEditorDialog onClose={onClose} initialFilePath="/in.pdf" />);
await userEvent.click(screen.getByText('Compress'));
const outputInputs = screen.getAllByPlaceholderText('output.pdf');
await userEvent.type(outputInputs[0], '/out.pdf');
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
await waitFor(() => expect(screen.getByText(/ipc channel missing/i)).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
it('can remove a merge file from the list', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
mockPickFile.mockResolvedValue({ ok: true, data: '/a.pdf' });
await userEvent.click(screen.getByRole('button', { name: /add file/i }));
expect(screen.getByText('/a.pdf')).toBeInTheDocument();
const removeBtns = screen.getAllByRole('button');
const removeBtn = removeBtns.find((btn) => btn.querySelector('svg.lucide-x'));
if (removeBtn) {
await userEvent.click(removeBtn);
expect(screen.queryByText('/a.pdf')).not.toBeInTheDocument();
}
});
it('switches tabs and clears error', async () => {
render(<PdfEditorDialog onClose={() => {}} />);
await userEvent.click(screen.getByRole('button', { name: /^apply$/i }));
expect(screen.getByText(/add at least 2 pdf files/i)).toBeInTheDocument();
await userEvent.click(screen.getByText('Compress'));
expect(screen.queryByText(/add at least 2 pdf files/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UniversalConverterDialog } from '@/components/modals/UniversalConverterDialog';
import { useAppStore } from '@/stores/app-store';
import { useSettingsStore } from '@/stores/settings-store';
const mockPickFile = vi.fn().mockResolvedValue('/path/to/image.png');
const mockPickFolder = vi
.fn()
.mockResolvedValueOnce('/input/folder')
.mockResolvedValueOnce('/output/folder');
const mockOn = vi.fn().mockReturnValue(() => {});
const mockConvert = vi.fn().mockResolvedValue(undefined);
const mockConvertBatch = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/ipc', () => ({
ipc: { file: { pickFile: mockPickFile, pickFolder: mockPickFolder } },
}));
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any);
(window.electronAPI as any) = {
file: {
pickFile: mockPickFile,
pickFolder: mockPickFolder,
},
on: mockOn,
converter: { convert: mockConvert, convertBatch: mockConvertBatch },
};
});
describe('UniversalConverterDialog', () => {
it('renders with tool selector and format dropdowns', () => {
render(<UniversalConverterDialog />);
expect(screen.getByText(/universal converter/i)).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /conversion tool/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /source format/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /target format/i })).toBeInTheDocument();
});
it('validates that both formats are selected before converting', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select both source and target formats/i)).toBeInTheDocument();
});
it('validates file selection in single-file mode after formats are selected', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('combobox', { name: /source format/i }));
await userEvent.click(screen.getByRole('option', { name: 'Markdown' }));
await userEvent.click(screen.getByRole('combobox', { name: /target format/i }));
await userEvent.click(screen.getByRole('option', { name: 'PDF' }));
await userEvent.click(screen.getByRole('button', { name: /^convert$/i }));
expect(screen.getByText(/select a file to convert/i)).toBeInTheDocument();
});
it('browse button calls pickFile and sets path', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('button', { name: /^browse$/i }));
await waitFor(() => expect(mockPickFile).toHaveBeenCalled());
expect(await screen.findByDisplayValue('/path/to/image.png')).toBeInTheDocument();
});
it('switches tool and resets format selections', async () => {
render(<UniversalConverterDialog />);
const toolSelect = screen.getByRole('combobox', { name: /conversion tool/i });
await userEvent.click(toolSelect);
await userEvent.click(screen.getByRole('option', { name: 'FFmpeg' }));
expect(toolSelect).toHaveTextContent('FFmpeg');
});
it('enables batch mode with folder inputs', async () => {
render(<UniversalConverterDialog />);
await userEvent.click(screen.getByRole('switch', { name: /batch mode/i }));
expect(screen.getByLabelText(/input folder/i)).toBeInTheDocument();
expect(screen.getByLabelText(/output folder/i)).toBeInTheDocument();
});
it('subscribes to conversion IPC events', () => {
render(<UniversalConverterDialog />);
expect(mockOn).toHaveBeenCalledWith('conversion-status', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('conversion-complete', expect.any(Function));
expect(mockOn).toHaveBeenCalledWith('batch-progress', expect.any(Function));
});
});
+76 -27
View File
@@ -1,21 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { WelcomeDialog } from '@/components/modals/WelcomeDialog'; import { WelcomeDialog } from '@/components/modals/WelcomeDialog';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
import { useAppStore } from '@/stores/app-store'; import { useAppStore } from '@/stores/app-store';
import { useCommandStore } from '@/stores/command-store';
describe('WelcomeDialog', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
useSettingsStore.setState(useSettingsStore.getInitialState()); useSettingsStore.setState(useSettingsStore.getInitialState());
useAppStore.setState({ modal: { kind: null } } as any); useAppStore.setState({ modal: { kind: null } } as any);
useCommandStore.setState({ handlers: {}, userBindings: {} } as any);
useCommandStore.getState().register('file.new', vi.fn());
useCommandStore.getState().register('file.open', vi.fn());
useCommandStore.getState().register('file.openFolder', vi.fn());
useCommandStore.getState().register('shortcuts.show', vi.fn());
useCommandStore.getState().register('file.opened', vi.fn());
(window.electronAPI as any) = {
app: {
getVersion: vi.fn().mockResolvedValue({ data: '1.2.3' }),
},
};
}); });
it('renders a heading and quick-start content', () => { describe('WelcomeDialog', () => {
it('renders a heading with app name', () => {
render(<WelcomeDialog />); render(<WelcomeDialog />);
expect(screen.getByRole('heading', { name: /welcome/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /welcome/i })).toBeInTheDocument();
expect(screen.getByText(/open a folder/i)).toBeInTheDocument(); });
it('renders the 3-column layout: Quick Start, Features, Recent Files', () => {
render(<WelcomeDialog />);
expect(screen.getByText(/quick start/i)).toBeInTheDocument();
expect(screen.getByText(/features/i)).toBeInTheDocument();
expect(screen.getAllByText(/recent files/i).length).toBeGreaterThanOrEqual(1);
});
it('renders all 4 quick start items with shortcuts', () => {
render(<WelcomeDialog />);
expect(screen.getByText('New File')).toBeInTheDocument();
expect(screen.getByText('Open File')).toBeInTheDocument();
expect(screen.getByText('Open Folder')).toBeInTheDocument();
expect(screen.getByText('Command Palette')).toBeInTheDocument();
expect(screen.getByText('Ctrl+N')).toBeInTheDocument();
expect(screen.getByText('Ctrl+O')).toBeInTheDocument();
});
it('renders feature cards', () => {
render(<WelcomeDialog />);
expect(screen.getByText('CodeMirror 6')).toBeInTheDocument();
expect(screen.getByText('Live Preview')).toBeInTheDocument();
expect(screen.getByText('PDF Editing')).toBeInTheDocument();
expect(screen.getByText('25+ Themes')).toBeInTheDocument();
});
it('renders keyboard shortcuts section', () => {
render(<WelcomeDialog />);
expect(screen.getByText(/shortcuts/i)).toBeInTheDocument();
expect(screen.getByText('Ctrl+S')).toBeInTheDocument();
expect(screen.getByText('Toggle sidebar')).toBeInTheDocument();
});
it('shows no recent files when none exist', () => {
render(<WelcomeDialog />);
expect(screen.getByText('No recent files')).toBeInTheDocument();
});
it('shows recent files from localStorage', () => {
localStorage.setItem(
'mc-recent-files',
JSON.stringify(['/home/user/doc1.md', '/home/user/doc2.md'])
);
render(<WelcomeDialog />);
expect(screen.getByText('doc1.md')).toBeInTheDocument();
expect(screen.getByText('doc2.md')).toBeInTheDocument();
}); });
it('closing without the checkbox does not dismiss future welcome dialogs', async () => { it('closing without the checkbox does not dismiss future welcome dialogs', async () => {
@@ -25,33 +83,24 @@ describe('WelcomeDialog', () => {
expect(useAppStore.getState().modal).toEqual({ kind: null }); expect(useAppStore.getState().modal).toEqual({ kind: null });
}); });
it('checking "don\'t show again" persists the flag', async () => { it('checking "don\'t show on startup" persists the flag', async () => {
render(<WelcomeDialog />); render(<WelcomeDialog />);
await userEvent.click(screen.getByRole('checkbox', { name: /don't show again/i })); await userEvent.click(screen.getByRole('checkbox', { name: /don't show/i }));
await userEvent.click(screen.getByRole('button', { name: /get started/i })); await userEvent.click(screen.getByRole('button', { name: /get started/i }));
expect(useSettingsStore.getState().welcomeDismissed).toBe(true); expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
}); });
// Regression guard: Radix Dialog emits a console.warn when the Content's it('quick start buttons dispatch commands and close', async () => {
// aria-describedby points at an id that isn't in the DOM. The default
// shadcn/ui pattern (custom id="X-desc" on DialogDescription) breaks that
// link because Radix's internal descriptionId is auto-generated via useId.
// The fix is to drop the override and let Radix manage the id itself.
it('does not emit the Radix "Missing Description" warning', async () => {
const warnings: string[] = [];
const spy = vi.spyOn(console, 'warn').mockImplementation((...args) => {
warnings.push(args.map(String).join(' '));
});
try {
render(<WelcomeDialog />); render(<WelcomeDialog />);
// Effects that fire the warning run in a microtask; one tick is enough. await userEvent.click(screen.getByText('New File'));
await Promise.resolve(); expect(useCommandStore.getState().handlers['file.new']).toBeDefined();
const offending = warnings.filter((w) => expect(useAppStore.getState().modal).toEqual({ kind: null });
/Missing\s+`?Description`?|aria-describedby=\{undefined\}/.test(w) });
);
expect(offending).toEqual([]); it('fetches and displays version number', async () => {
} finally { render(<WelcomeDialog />);
spy.mockRestore(); await waitFor(() => {
} expect(screen.getByText(/v1\.2\.3/)).toBeInTheDocument();
});
}); });
}); });
@@ -7,9 +7,7 @@ import { useEditorStore } from '@/stores/editor-store';
import { useSettingsStore } from '@/stores/settings-store'; import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/lib/docx-export', () => ({ vi.mock('@/lib/docx-export', () => ({
generateDocx: vi generateDocx: vi.fn().mockResolvedValue(
.fn()
.mockResolvedValue(
new Blob([new Uint8Array([1, 2, 3])], { new Blob([new Uint8Array([1, 2, 3])], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}) })
@@ -12,8 +12,30 @@ vi.mock('@/lib/ipc', () => ({
}, },
})); }));
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 mockGitCommit = vi.fn();
Object.defineProperty(window, 'electronAPI', {
value: {
gitStage: mockGitStage,
gitCommit: mockGitCommit,
},
writable: true,
});
describe('GitStatusPanel', () => { describe('GitStatusPanel', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
@@ -54,7 +76,6 @@ describe('GitStatusPanel', () => {
}); });
useFileStore.setState({ rootPath: '/project' } as any); useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />); render(<GitStatusPanel />);
// The helper text appears in a <p> element distinct from the error heading
expect( expect(
await screen.findByText('Not a git repository, or git not installed.') await screen.findByText('Not a git repository, or git not installed.')
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -72,4 +93,65 @@ describe('GitStatusPanel', () => {
await userEvent.click(row); await userEvent.click(row);
expect(openFile).toHaveBeenCalledWith('/project/a.md'); expect(openFile).toHaveBeenCalledWith('/project/a.md');
}); });
it('shows stage and commit UI when files are changed', async () => {
(ipc.file.gitStatus as any).mockResolvedValueOnce({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
expect(await screen.findByText('Select All')).toBeInTheDocument();
expect(screen.getByText('Stage Selected')).toBeInTheDocument();
expect(screen.getByText('Stage All')).toBeInTheDocument();
expect(screen.getByTestId('git-commit-input')).toBeInTheDocument();
expect(screen.getByTestId('git-commit-button')).toBeInTheDocument();
});
it('selects and deselects files via checkboxes and select all', async () => {
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [
{ filePath: '/project/a.md', status: 'modified' },
{ filePath: '/project/b.md', status: 'added' },
],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
const selectAllBtn = await screen.findByText('Select All');
await userEvent.click(selectAllBtn);
expect(screen.getByText('Deselect All')).toBeInTheDocument();
await userEvent.click(screen.getByText('Deselect All'));
expect(screen.getByText('Select All')).toBeInTheDocument();
});
it('stages selected files', async () => {
mockGitStage.mockResolvedValueOnce(undefined);
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
const checkbox = await screen.findByTestId('git-status-checkbox');
await userEvent.click(checkbox);
const stageBtn = screen.getByTestId('git-stage-selected');
await userEvent.click(stageBtn);
expect(mockGitStage).toHaveBeenCalledWith(['/project/a.md']);
});
it('commits with message', async () => {
mockGitCommit.mockResolvedValueOnce(undefined);
(ipc.file.gitStatus as any).mockResolvedValue({
ok: true,
data: [{ filePath: '/project/a.md', status: 'modified' }],
});
useFileStore.setState({ rootPath: '/project' } as any);
render(<GitStatusPanel />);
const input = await screen.findByTestId('git-commit-input');
await userEvent.type(input, 'fix typo');
const commitBtn = screen.getByTestId('git-commit-button');
await userEvent.click(commitBtn);
expect(mockGitCommit).toHaveBeenCalledWith('fix typo');
});
}); });
+1 -1
View File
@@ -135,7 +135,7 @@ describe('Markdown Extensions', () => {
tokens: [{ type: 'text', text: 'Be careful!' }], tokens: [{ type: 'text', text: 'Be careful!' }],
}; };
const mockParser = { const mockParser = {
parse: jest.fn((tokens) => '<p>Be careful!</p>'), parse: jest.fn((_tokens) => '<p>Be careful!</p>'),
}; };
const context = { parser: mockParser }; const context = { parser: mockParser };
const html = extension.renderer.call(context, token); const html = extension.renderer.call(context, token);
-3
View File
@@ -8,7 +8,6 @@ describe('PDFOperations Utilities', () => {
describe('page range parsing', () => { describe('page range parsing', () => {
it('should parse single page numbers', () => { it('should parse single page numbers', () => {
// Test logic: parsing "1" should extract page index 0 // Test logic: parsing "1" should extract page index 0
const input = '1';
const pages = [0]; // Parsed result const pages = [0]; // Parsed result
expect(pages.length).toBe(1); expect(pages.length).toBe(1);
@@ -17,7 +16,6 @@ describe('PDFOperations Utilities', () => {
it('should parse page ranges', () => { it('should parse page ranges', () => {
// Test logic: parsing "1-3" should extract pages 0, 1, 2 // Test logic: parsing "1-3" should extract pages 0, 1, 2
const input = '1-3';
const pages = [0, 1, 2]; // Expected result const pages = [0, 1, 2]; // Expected result
expect(pages.length).toBe(3); expect(pages.length).toBe(3);
@@ -26,7 +24,6 @@ describe('PDFOperations Utilities', () => {
it('should handle multiple ranges', () => { it('should handle multiple ranges', () => {
// Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4 // Test logic: parsing "1-2,4-5" should extract pages 0,1,3,4
const input = '1-2,4-5';
const pages = [0, 1, 3, 4]; // Expected result const pages = [0, 1, 3, 4]; // Expected result
expect(pages.length).toBe(4); expect(pages.length).toBe(4);
-2
View File
@@ -80,8 +80,6 @@ describe('Security: Path Handling', () => {
describe('validation patterns', () => { describe('validation patterns', () => {
it('should validate path existence check pattern', () => { it('should validate path existence check pattern', () => {
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
const validPaths = ['documents/file.md', 'folder_2026/data.csv']; const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
validPaths.forEach((pathStr) => { validPaths.forEach((pathStr) => {
// These should match a reasonable filename pattern // These should match a reasonable filename pattern
+1 -1
View File
@@ -77,7 +77,7 @@ global.window.DOMPurify = {
// Mock highlight.js // Mock highlight.js
global.window.hljs = { global.window.hljs = {
highlight: jest.fn((code, options) => ({ value: code })), highlight: jest.fn((code, _options) => ({ value: code })),
highlightAuto: jest.fn((code) => ({ value: code })), highlightAuto: jest.fn((code) => ({ value: code })),
getLanguage: jest.fn(() => true), getLanguage: jest.fn(() => true),
}; };
+1 -1
View File
@@ -30,7 +30,7 @@ describe('SidebarManager', () => {
}); });
test('starts collapsed', () => { test('starts collapsed', () => {
const mgr = new SidebarManager(); new SidebarManager();
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true); expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
}); });
@@ -70,4 +70,25 @@ describe('modal commands', () => {
props: { sourcePaths: ['/x.md', '/y.md'] }, props: { sourcePaths: ['/x.md', '/y.md'] },
}); });
}); });
it('settings.headerFooter opens header-footer modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('settings.headerFooter');
expect(useAppStore.getState().modal).toEqual({ kind: 'header-footer' });
});
it('tools.universalConverter opens universal-converter modal', () => {
registerMenuCommands();
useCommandStore.getState().dispatch('tools.universalConverter');
expect(useAppStore.getState().modal).toEqual({ kind: 'universal-converter' });
});
it('shortcuts.show dispatches command palette toggle event', () => {
const listener = vi.fn();
window.addEventListener('mc:command-palette-toggle', listener);
registerMenuCommands();
useCommandStore.getState().dispatch('shortcuts.show');
expect(listener).toHaveBeenCalled();
window.removeEventListener('mc:command-palette-toggle', listener);
});
}); });