mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
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:
+1
-1
@@ -85,7 +85,7 @@ module.exports = [
|
||||
},
|
||||
rules: {
|
||||
// Error prevention
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
'no-undef': 'error',
|
||||
'no-console': 'off', // Allow console for Electron apps
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ async function getStatus(dir) {
|
||||
try {
|
||||
const git = getGitInstance(dir);
|
||||
return await git.status();
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
return { error: 'Not a git repository' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/main/files/index.js
|
||||
// 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 path = require('path');
|
||||
const { register: registerGit } = require('./git');
|
||||
|
||||
+33
-28
@@ -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 { UpdaterService } = require('./updater/updater-service');
|
||||
const { CrashWriter } = require('./updater/crash-writer');
|
||||
@@ -7,16 +7,10 @@ const updaterHandlers = require('./ipc/updater-handlers');
|
||||
const crashHandlers = require('./ipc/crash-handlers');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec, execFile, spawn } = require('child_process');
|
||||
const { execFile } = require('child_process');
|
||||
const WordTemplateExporter = require('./word-template');
|
||||
const PDFOperations = require('./PDFOperations');
|
||||
const GitOperations = require('./GitOperations');
|
||||
const {
|
||||
getAllowedDirectories,
|
||||
validatePath,
|
||||
resolveWritablePath,
|
||||
isPathAccessible,
|
||||
} = require('./utils/paths');
|
||||
const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths');
|
||||
const fileOps = require('./files');
|
||||
const menu = require('./menu');
|
||||
const { createMainWindow } = require('./window');
|
||||
@@ -57,13 +51,13 @@ function getFFmpegPath() {
|
||||
ffmpegPath = ffmpegPath.replace('app.asar' + path.sep, 'app.asar.unpacked' + path.sep);
|
||||
}
|
||||
if (fs.existsSync(ffmpegPath)) return ffmpegPath;
|
||||
} catch (_) {
|
||||
} catch (_ffmpegErr) {
|
||||
/* ffmpeg-static not available */
|
||||
}
|
||||
return process.platform === 'win32' ? 'ffmpeg' : 'ffmpeg';
|
||||
}
|
||||
|
||||
// Check if Pandoc is available
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function checkPandocAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
const pandocPath = getPandocPath();
|
||||
@@ -80,6 +74,7 @@ function checkPandocAvailable() {
|
||||
* @param {object} options - Options for execFile
|
||||
* @returns {Promise<{stdout: string, stderr: string}>}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function safeExecFile(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
@@ -144,6 +139,7 @@ function convertDataToMarkdown(content, format) {
|
||||
* @param {string[]} args - Pandoc arguments array
|
||||
* @param {Function} callback - Callback function (error, stdout, stderr)
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function runPandoc(args, callback) {
|
||||
const pandocPath = getPandocPath();
|
||||
execFile(pandocPath, args, { maxBuffer: 10 * 1024 * 1024 }, callback);
|
||||
@@ -373,7 +369,7 @@ function checkPandocAvailability() {
|
||||
return;
|
||||
}
|
||||
|
||||
execFile('pandoc', ['--version'], (error, stdout, stderr) => {
|
||||
execFile('pandoc', ['--version'], (error, _stdout, _stderr) => {
|
||||
pandocAvailable = !error;
|
||||
resolve(pandocAvailable);
|
||||
});
|
||||
@@ -597,7 +593,7 @@ function showDependenciesDialog() {
|
||||
depsWindow.setMenuBarVisibility(false);
|
||||
}
|
||||
|
||||
// Open PDF File for viewing/editing
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function openPDFFile() {
|
||||
const files = dialog.showOpenDialogSync(mainWindow, {
|
||||
properties: ['openFile'],
|
||||
@@ -1198,7 +1194,7 @@ async function exportPDFViaWordTemplate() {
|
||||
const outputDir = path.dirname(result.filePath);
|
||||
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
|
||||
try {
|
||||
fs.unlinkSync(tempDocxPath);
|
||||
@@ -1290,7 +1286,7 @@ function checkConverterAvailable(tool) {
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||||
return;
|
||||
@@ -1378,7 +1374,15 @@ ipcMain.on(
|
||||
'universal-convert-batch',
|
||||
async (
|
||||
event,
|
||||
{ tool, fromFormat, toFormat, inputFolder, outputFolder, includeSubfolders, advancedOptions }
|
||||
{
|
||||
tool,
|
||||
fromFormat,
|
||||
toFormat,
|
||||
inputFolder,
|
||||
outputFolder,
|
||||
includeSubfolders,
|
||||
advancedOptions: _advancedOptions,
|
||||
}
|
||||
) => {
|
||||
if (!conversionLimiter()) {
|
||||
mainWindow.webContents.send('conversion-status', 'Please wait before converting again...');
|
||||
@@ -1776,7 +1780,7 @@ function performExportWithOptions(format, options) {
|
||||
// Clean up intermediate EPUB
|
||||
try {
|
||||
fs.unlinkSync(epubFile);
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
/* ignore */
|
||||
}
|
||||
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) {
|
||||
// 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');
|
||||
@@ -1966,13 +1970,14 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
|
||||
}
|
||||
|
||||
// Helper function to export PDF with pandoc (with fallbacks) - uses runPandocCmd for safety
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function exportWithPandocPDF(pandocCmd, outputFile) {
|
||||
runPandocCmd(pandocCmd, (error, stdout, stderr) => {
|
||||
runPandocCmd(pandocCmd, (error, _stdout, _stderr) => {
|
||||
if (error) {
|
||||
console.log('XeLaTeX failed, trying PDFLaTeX...');
|
||||
// Fallback to pdflatex
|
||||
const fallbackCmd = pandocCmd.replace('--pdf-engine=xelatex', '--pdf-engine=pdflatex');
|
||||
runPandocCmd(fallbackCmd, (fallbackError, fallbackStdout, fallbackStderr) => {
|
||||
runPandocCmd(fallbackCmd, (fallbackError, _fallbackStdout, _fallbackStderr) => {
|
||||
if (fallbackError) {
|
||||
console.log('PDFLaTeX failed, trying Electron PDF...');
|
||||
// Final fallback to Electron PDF
|
||||
@@ -2337,7 +2342,7 @@ function importDocument() {
|
||||
// Convert to markdown using pandoc (using runPandocCmd for safety)
|
||||
const pandocCmd = `${getPandocPath()} "${inputFile}" -t markdown ${additionalOptions} -o "${outputFile}"`;
|
||||
|
||||
runPandocCmd(pandocCmd, (error, stdout, stderr) => {
|
||||
runPandocCmd(pandocCmd, (error, _stdout, _stderr) => {
|
||||
if (error) {
|
||||
dialog.showErrorBox(
|
||||
'Import Error',
|
||||
@@ -2457,7 +2462,7 @@ ipcMain.on('do-print-with-options', (event, options) => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
@@ -2675,7 +2680,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||||
|
||||
const inputFile = markdownFiles[index];
|
||||
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;
|
||||
if (format === 'docx-enhanced') outputExtension = 'docx';
|
||||
if (format === 'pdf-enhanced') outputExtension = 'pdf';
|
||||
@@ -2709,7 +2714,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||||
|
||||
// Process next file
|
||||
processNextFile(index + 1);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Update progress with error
|
||||
mainWindow.webContents.send('batch-progress', {
|
||||
completed: index + 1,
|
||||
@@ -2750,7 +2755,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||||
tempDocxPath,
|
||||
];
|
||||
|
||||
execFile(soffice, sofficeArgs, (error, stdout, stderr) => {
|
||||
execFile(soffice, sofficeArgs, (error, _stdout, _stderr) => {
|
||||
// Clean up temporary DOCX file
|
||||
try {
|
||||
fs.unlinkSync(tempDocxPath);
|
||||
@@ -2795,7 +2800,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||||
// Process next file
|
||||
processNextFile(index + 1);
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Update progress with error
|
||||
mainWindow.webContents.send('batch-progress', {
|
||||
completed: index + 1,
|
||||
@@ -2904,7 +2909,7 @@ function performBatchConversion(inputFolder, outputFolder, format, options) {
|
||||
}
|
||||
|
||||
// Execute conversion (using runPandocCmd for safety)
|
||||
runPandocCmd(pandocCmd, async (error, stdout, stderr) => {
|
||||
runPandocCmd(pandocCmd, async (error, _stdout, _stderr) => {
|
||||
if (!error) {
|
||||
// Add headers/footers to DOCX if 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, {
|
||||
title: 'Select Custom Preview CSS',
|
||||
properties: ['openFile'],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { ipcMain, shell } = require('electron');
|
||||
|
||||
function register({ crash, getMainWindow }) {
|
||||
function register({ crash, getMainWindow: _getMainWindow }) {
|
||||
ipcMain.handle('crash:read', () => {
|
||||
return crash.list();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/main/menu/items.js
|
||||
// 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 fs = require('fs');
|
||||
|
||||
@@ -32,7 +32,7 @@ function buildRecentFilesMenu(mainWindow) {
|
||||
}
|
||||
);
|
||||
return items;
|
||||
} catch (e) {
|
||||
} catch (_e) {
|
||||
return [{ label: 'No Recent Files', enabled: false }];
|
||||
}
|
||||
}
|
||||
@@ -628,7 +628,7 @@ function batchItems(mainWindow) {
|
||||
];
|
||||
}
|
||||
|
||||
function convertItems(mainWindow) {
|
||||
function convertItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'Universal File Converter...',
|
||||
@@ -641,7 +641,7 @@ function convertItems(mainWindow) {
|
||||
];
|
||||
}
|
||||
|
||||
function pdfEditorItems(mainWindow) {
|
||||
function pdfEditorItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'Open PDF File...',
|
||||
@@ -754,7 +754,7 @@ function toolsItems(mainWindow) {
|
||||
];
|
||||
}
|
||||
|
||||
function helpItems(mainWindow) {
|
||||
function helpItems(_mainWindow) {
|
||||
return [
|
||||
{
|
||||
label: 'About MarkdownConverter',
|
||||
|
||||
@@ -31,7 +31,7 @@ class CrashWriter {
|
||||
const oldest = files.shift();
|
||||
try {
|
||||
fs.unlinkSync(path.join(this.dir, oldest));
|
||||
} catch (_) {
|
||||
} catch (_unlinkErr) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function validatePath(filePath) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.resolve(filePath);
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function resolveWritablePath(filePath) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = path.normalize(path.resolve(filePath));
|
||||
} catch (err) {
|
||||
} catch (_err2) {
|
||||
return { valid: false, resolved: '', error: 'Invalid path format' };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const PizZip = require('pizzip');
|
||||
const Docx = require('docx4js').default;
|
||||
|
||||
class WordTemplateExporter {
|
||||
constructor(templatePath, startPage = 3, pageSettings = null) {
|
||||
@@ -432,7 +431,7 @@ class WordTemplateExporter {
|
||||
rowCells.push('');
|
||||
}
|
||||
|
||||
rowCells.forEach((cellText, colIndex) => {
|
||||
rowCells.forEach((cellText, _colIndex) => {
|
||||
tableXml += '<w:tc>';
|
||||
tableXml += '<w:tcPr>';
|
||||
|
||||
@@ -715,7 +714,7 @@ class WordTemplateExporter {
|
||||
*/
|
||||
parseInlineFormatting(text) {
|
||||
let xml = '';
|
||||
const pos = 0;
|
||||
const _pos = 0;
|
||||
|
||||
// Patterns for inline formatting
|
||||
const patterns = [
|
||||
|
||||
@@ -80,7 +80,7 @@ function renderIssues(container, issues) {
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'ws-issue-actions';
|
||||
for (const [action, label] of [
|
||||
for (const [_action, label] of [
|
||||
['accept', 'Accept'],
|
||||
['dismiss', 'Dismiss'],
|
||||
]) {
|
||||
|
||||
@@ -14,7 +14,7 @@ class SettingsStore {
|
||||
this.backend.set(key, value);
|
||||
}
|
||||
|
||||
onChanged(key, callback) {
|
||||
onChanged(_key, _callback) {
|
||||
// Deferred: plugins read settings on init/activate for MVP.
|
||||
// Full change notification requires IPC watcher in main process.
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'app:quit',
|
||||
'app:open-external',
|
||||
'app:show-save-dialog',
|
||||
'get-app-version',
|
||||
|
||||
// Updater
|
||||
'updater:check',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppShell } from './components/layout/AppShell';
|
||||
import { ModalLayer } from './components/modals/ModalLayer';
|
||||
import { CommandPalette } from './components/modals/CommandPalette';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { ReplPanel } from './components/tools/ReplPanel';
|
||||
import { PrintPreview } from './components/tools/PrintPreview';
|
||||
@@ -30,6 +31,7 @@ function App() {
|
||||
<>
|
||||
<AppShell />
|
||||
<ModalLayer />
|
||||
<CommandPalette />
|
||||
<Toaster />
|
||||
<UpdateBanner />
|
||||
<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 {
|
||||
EditorView,
|
||||
@@ -16,8 +16,10 @@ import { useTheme } from 'next-themes';
|
||||
import { lightTheme, lightHighlight } from './themes/light';
|
||||
import { useEditorStore } from '@/stores/editor-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 { toast } from '@/lib/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
bufferId: string;
|
||||
@@ -26,6 +28,63 @@ interface Props {
|
||||
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(``);
|
||||
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) {
|
||||
const ref = useRef<HTMLDivElement>(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 minimap = useSettingsStore((s) => s.minimap);
|
||||
const editorFontSize = useSettingsStore((s) => s.editorFontSize);
|
||||
// Live scroll metrics for the minimap. `0..1` ratios; defaults match
|
||||
// Minimap's static defaults so it renders before any scroll event.
|
||||
const buffer = useEditorStore((s) => s.buffers.get(bufferId));
|
||||
const content = buffer?.content ?? initialContent;
|
||||
const [scrollRatio, setScrollRatio] = useState(0);
|
||||
const [visibleRatio, setVisibleRatio] = useState(1);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -92,6 +152,7 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
createPasteHandler(),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: ref.current });
|
||||
@@ -115,11 +176,42 @@ export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorC
|
||||
});
|
||||
}, [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 (
|
||||
<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 content={initialContent} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
|
||||
<Minimap content={content} scrollRatio={scrollRatio} visibleRatio={visibleRatio} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CodeMirrorEditor } from './CodeMirrorEditor';
|
||||
import { FindReplaceBar } from './FindReplaceBar';
|
||||
import { useEditorStore } from '@/stores/editor-store';
|
||||
import { usePreviewStore } from '@/stores/preview-store';
|
||||
|
||||
@@ -21,8 +22,11 @@ export function EditorPane() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} />
|
||||
<div className="flex h-full flex-col">
|
||||
<FindReplaceBar />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeMirrorEditor key={buf.id} bufferId={buf.id} initialContent={buf.content} />
|
||||
</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,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useExportSource } from '@/hooks/use-export-source';
|
||||
@@ -29,6 +31,11 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const source = useExportSource();
|
||||
const [template, setTemplate] = useState<'standard' | 'minimal' | 'modern'>(docxTemplate);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -49,6 +56,17 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
setSubmitting(false);
|
||||
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 writeResult = await ipc.file.writeBuffer({ path: saveResult.data, buffer });
|
||||
if (!writeResult.ok) {
|
||||
@@ -68,7 +86,7 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to DOCX</DialogTitle>
|
||||
<DialogDescription>{sourcePath}</DialogDescription>
|
||||
@@ -99,6 +117,49 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) {
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</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 && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useExportSource } from '@/hooks/use-export-source';
|
||||
@@ -30,6 +32,11 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
const [standalone, setStandalone] = useState(true);
|
||||
const [highlight, setHighlight] = useState(htmlHighlightStyle);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -48,6 +55,19 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
highlightStyle: highlight,
|
||||
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?.({
|
||||
title: 'Save as HTML',
|
||||
defaultPath: source.path.replace(/\.md$/, '.html'),
|
||||
@@ -75,7 +95,7 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to HTML</DialogTitle>
|
||||
<DialogDescription>{sourcePath}</DialogDescription>
|
||||
@@ -103,6 +123,10 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
<Checkbox
|
||||
checked={ascii}
|
||||
@@ -111,6 +135,39 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</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 && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
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 [embed, setEmbed] = useState(pdfEmbedFonts);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const source = useExportSource();
|
||||
@@ -49,8 +60,6 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Renderer-side: build the standalone HTML and hand it to the main
|
||||
// process for native print-to-PDF.
|
||||
const html = generateHtml({
|
||||
source: source.source,
|
||||
title: source.title,
|
||||
@@ -67,7 +76,24 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
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 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) {
|
||||
const msg = result.error?.message ?? 'PDF export failed';
|
||||
toast.error(`Export failed: ${msg}`);
|
||||
@@ -87,7 +113,7 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && closeModal()}>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export to PDF</DialogTitle>
|
||||
<DialogDescription>{sourcePath}</DialogDescription>
|
||||
@@ -135,6 +161,99 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) {
|
||||
/>
|
||||
Render tables as ASCII
|
||||
</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 && (
|
||||
<div
|
||||
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 { AboutDialog } from './AboutDialog';
|
||||
import { BatchMediaConverterDialog } from './BatchMediaConverterDialog';
|
||||
import { AsciiGeneratorDialog } from './AsciiGeneratorDialog';
|
||||
import { ConfirmDialog } from './ConfirmDialog';
|
||||
import { CrashReportModal } from './CrashReportModal';
|
||||
@@ -8,11 +9,14 @@ import { ExportDocxDialog } from './ExportDocxDialog';
|
||||
import { ExportHtmlDialog } from './ExportHtmlDialog';
|
||||
import { ExportPdfDialog } from './ExportPdfDialog';
|
||||
import { FindInFilesDialog } from './FindInFilesDialog';
|
||||
import { HeaderFooterDialog } from './HeaderFooterDialog';
|
||||
import { SettingsSheet } from './SettingsSheet';
|
||||
import { TableGeneratorDialog } from './TableGeneratorDialog';
|
||||
import { UniversalConverterDialog } from './UniversalConverterDialog';
|
||||
import { WelcomeDialog } from './WelcomeDialog';
|
||||
import { WordExportDialog } from './WordExportDialog';
|
||||
import { WritingAnalyticsDialog } from './WritingAnalyticsDialog';
|
||||
import { PdfEditorDialog } from './PdfEditorDialog';
|
||||
|
||||
export function ModalLayer() {
|
||||
const modal = useAppStore((s) => s.modal);
|
||||
@@ -47,5 +51,18 @@ export function ModalLayer() {
|
||||
return <CrashReportModal onClose={useAppStore.getState().closeModal} />;
|
||||
case 'writing-analytics':
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -11,44 +11,186 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useAppStore } from '@/stores/app-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() {
|
||||
const closeModal = useAppStore((s) => s.closeModal);
|
||||
const setSetting = useSettingsStore((s) => s.setSetting);
|
||||
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 = () => {
|
||||
if (dontShow) setSetting('welcomeDismissed', true);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
const handleQuickStart = (commandId: string) => {
|
||||
useCommandStore.getState().dispatch(commandId);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Welcome to MarkdownConverter</DialogTitle>
|
||||
<DialogTitle className="text-xl">
|
||||
Welcome to MarkdownConverter{version ? ` v${version}` : ''}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-border bg-card/30 p-3">
|
||||
<h3 className="font-semibold">1. Open a folder</h3>
|
||||
<p className="text-muted-foreground">
|
||||
File → Open Folder, or ⌘O. Your tree appears on the left.
|
||||
</p>
|
||||
</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 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 className="grid gap-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Quick Start
|
||||
</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>
|
||||
</div>
|
||||
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
|
||||
@@ -58,7 +200,7 @@ export function WelcomeDialog() {
|
||||
onCheckedChange={(c) => setDontShow(!!c)}
|
||||
aria-label="Don't show again"
|
||||
/>
|
||||
Don't show again
|
||||
Don't show on startup
|
||||
</label>
|
||||
<Button onClick={handleClose}>Get started</Button>
|
||||
</DialogFooter>
|
||||
@@ -66,3 +208,110 @@ export function WelcomeDialog() {
|
||||
</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 { 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 { Input } from '@/components/ui/input';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import { ipc } from '@/lib/ipc';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface GitStatus {
|
||||
filePath: string;
|
||||
@@ -29,6 +41,10 @@ export function GitStatusPanel() {
|
||||
const [status, setStatus] = useState<GitStatus[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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 () => {
|
||||
if (!rootPath) {
|
||||
@@ -50,12 +66,59 @@ export function GitStatusPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Listen for git.refresh command via custom event
|
||||
const handler = () => load();
|
||||
window.addEventListener('mc:git-refresh', handler);
|
||||
return () => window.removeEventListener('mc:git-refresh', handler);
|
||||
}, [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) {
|
||||
return <div className="p-3 text-xs text-muted-foreground">No folder open</div>;
|
||||
}
|
||||
@@ -77,7 +140,7 @@ export function GitStatusPanel() {
|
||||
}
|
||||
|
||||
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">
|
||||
<span className="font-semibold">
|
||||
{status.length} changed file{status.length === 1 ? '' : 's'}
|
||||
@@ -86,19 +149,96 @@ export function GitStatusPanel() {
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{status.map((s) => (
|
||||
<button
|
||||
key={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"
|
||||
data-testid="git-status-row"
|
||||
>
|
||||
{STATUS_ICON[s.status]}
|
||||
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<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
|
||||
key={s.filePath}
|
||||
onClick={() => openFile(s.filePath)}
|
||||
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"
|
||||
>
|
||||
<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]}
|
||||
<span className="w-3 font-mono text-xs">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="truncate font-mono">{s.filePath.replace(rootPath + '/', '')}</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -51,23 +51,7 @@ export function registerMenuCommands(): void {
|
||||
'help.welcome': () => useAppStore.getState().openModal('welcome'),
|
||||
'shortcuts.show': () => {
|
||||
const open: OpenModal = useAppStore.getState().openModal;
|
||||
open('confirm', {
|
||||
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,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mc:command-palette-toggle'));
|
||||
},
|
||||
'file.exportPdf': () => {
|
||||
const activeTabId = useFileStore.getState().activeTabId;
|
||||
@@ -259,10 +243,20 @@ export function registerMenuCommands(): void {
|
||||
'tools.documentCompare': () => {
|
||||
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': () => {
|
||||
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': () => {
|
||||
|
||||
@@ -95,6 +95,14 @@ export const ipc = {
|
||||
path: string;
|
||||
buffer: Uint8Array;
|
||||
}): 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 => {
|
||||
if (typeof window !== 'undefined' && (window.electronAPI as any)?.file?.setCurrent) {
|
||||
(window.electronAPI as any).file.setCurrent(path);
|
||||
|
||||
@@ -32,7 +32,11 @@ export type ModalState =
|
||||
| { kind: 'table-generator' }
|
||||
| { kind: 'find-in-files' }
|
||||
| { 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'];
|
||||
|
||||
@@ -43,10 +47,12 @@ interface AppState {
|
||||
paneSizes: PaneSizes;
|
||||
modal: ModalState;
|
||||
firstRun: boolean;
|
||||
findBarOpen: boolean;
|
||||
toggleSidebar: () => void;
|
||||
togglePreview: () => void;
|
||||
setZenMode: (value: boolean) => void;
|
||||
setPaneSizes: (sizes: PaneSizes) => void;
|
||||
toggleFindBar: () => void;
|
||||
openModal: <K extends NonNullable<ModalKind>>(
|
||||
kind: K,
|
||||
...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 },
|
||||
modal: { kind: null },
|
||||
firstRun: true,
|
||||
findBarOpen: false,
|
||||
toggleSidebar: () => set((s) => ({ sidebarVisible: !s.sidebarVisible })),
|
||||
togglePreview: () => set((s) => ({ previewVisible: !s.previewVisible })),
|
||||
toggleFindBar: () => set((s) => ({ findBarOpen: !s.findBarOpen })),
|
||||
setZenMode: (value) => set({ zenMode: value }),
|
||||
setPaneSizes: (sizes) => set({ paneSizes: sizes }),
|
||||
openModal: (kind, ...args) =>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const path = require('path');
|
||||
|
||||
function renderExplorerPanel(container, { listDirectory, onFileOpen, currentDir }) {
|
||||
container.innerHTML = `
|
||||
<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
|
||||
.map((entry) => {
|
||||
if (entry.isDirectory) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function renderGitPanel(container, { gitStatus, gitDiff, gitStage, gitCommit, gitLog }) {
|
||||
function renderGitPanel(container, { gitStatus, gitDiff: _gitDiff, gitStage, gitCommit, gitLog }) {
|
||||
container.innerHTML = `
|
||||
<div class="git-panel">
|
||||
<div class="git-section">
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const templates = [
|
||||
{ name: 'Blog Post', file: 'blog-post.md', description: 'Article with frontmatter' },
|
||||
{ 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,12 +8,10 @@ describe('ExportBatchDialog', () => {
|
||||
localStorage.clear();
|
||||
window.electronAPI = {
|
||||
export: {
|
||||
batch: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { total: 2, succeeded: 2, failed: 0, results: [] },
|
||||
}),
|
||||
batch: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
data: { total: 2, succeeded: 2, failed: 0, results: [] },
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
});
|
||||
|
||||
@@ -14,46 +14,65 @@ vi.mock('@/lib/ipc', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/docx-export', () => ({
|
||||
generateDocx: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Blob([new Uint8Array(8)], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
})
|
||||
),
|
||||
generateDocx: vi.fn().mockResolvedValue(
|
||||
new Blob([new Uint8Array(8)], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
import { ipc } from '@/lib/ipc';
|
||||
|
||||
describe('ExportDocxDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// The dialog passes the source path with .docx extension as the default
|
||||
// path, and the test mock returns that same path (echoing the default).
|
||||
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
|
||||
ok: true,
|
||||
data: args?.defaultPath ?? '/out.docx',
|
||||
}));
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
(window.electronAPI as any) = {};
|
||||
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
|
||||
ok: true,
|
||||
data: args?.defaultPath ?? '/out.docx',
|
||||
}));
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('ExportDocxDialog', () => {
|
||||
it('renders with standard template selected by default', () => {
|
||||
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to docx/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 () => {
|
||||
render(<ExportDocxDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^export$/i }));
|
||||
@@ -71,4 +90,16 @@ describe('ExportDocxDialog', () => {
|
||||
await userEvent.click(screen.getByRole('option', { name: /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,27 +15,28 @@ vi.mock('@/lib/ipc', () => ({
|
||||
|
||||
import { ipc } from '@/lib/ipc';
|
||||
|
||||
describe('ExportHtmlDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
|
||||
ok: true,
|
||||
data: args?.defaultPath ?? '/out.html',
|
||||
}));
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
(window.electronAPI as any) = {};
|
||||
(ipc.app.showSaveDialog as any).mockImplementation(async (args) => ({
|
||||
ok: true,
|
||||
data: args?.defaultPath ?? '/out.html',
|
||||
}));
|
||||
(ipc.file.writeBuffer as any).mockResolvedValue({ ok: true });
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('ExportHtmlDialog', () => {
|
||||
it('renders with default github highlight style', () => {
|
||||
render(<ExportHtmlDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to html/i)).toBeInTheDocument();
|
||||
@@ -49,9 +50,41 @@ describe('ExportHtmlDialog', () => {
|
||||
expect(ipc.file.writeBuffer).toHaveBeenCalledTimes(1);
|
||||
const callArg = (ipc.file.writeBuffer as any).mock.calls[0][0];
|
||||
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.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,28 +17,63 @@ vi.mock('@/lib/ipc', () => ({
|
||||
|
||||
import { ipc } from '@/lib/ipc';
|
||||
|
||||
describe('ExportPdfDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
delete (window as any).electronAPI;
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useFileStore.setState({
|
||||
activeTabId: '/test.md',
|
||||
openTabs: [{ id: '/test.md', path: '/test.md', title: 'test.md', dirty: false }],
|
||||
} as any);
|
||||
useEditorStore.setState({
|
||||
buffers: new Map([
|
||||
['/test.md', { id: '/test.md', path: '/test.md', content: '# hi', dirty: false }],
|
||||
]),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe('ExportPdfDialog', () => {
|
||||
it('renders with default PDF options from settings', () => {
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
expect(screen.getByText(/export to pdf/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 () => {
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /ascii/i }));
|
||||
@@ -46,16 +81,30 @@ describe('ExportPdfDialog', () => {
|
||||
await waitFor(() => expect(ipc.print.show).toHaveBeenCalledTimes(1));
|
||||
const [arg] = (ipc.print.show as any).mock.calls[0];
|
||||
expect(arg.html).toContain('<!DOCTYPE html>');
|
||||
// The Markdown source is rendered to HTML inside the document body.
|
||||
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/);
|
||||
});
|
||||
|
||||
it('renders an error banner when IPC fails', async () => {
|
||||
(ipc.print.show as any).mockRejectedValueOnce(new Error('Pandoc not found'));
|
||||
it('uses electronAPI.export.withOptions when available', async () => {
|
||||
const mockWithOptions = vi.fn().mockResolvedValue({ ok: true });
|
||||
(window.electronAPI as any) = {
|
||||
export: { withOptions: mockWithOptions },
|
||||
};
|
||||
render(<ExportPdfDialog sourcePath="/test.md" />);
|
||||
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.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));
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { WelcomeDialog } from '@/components/modals/WelcomeDialog';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useAppStore } from '@/stores/app-store';
|
||||
import { useCommandStore } from '@/stores/command-store';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
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' }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('WelcomeDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.setState(useSettingsStore.getInitialState());
|
||||
useAppStore.setState({ modal: { kind: null } } as any);
|
||||
});
|
||||
|
||||
it('renders a heading and quick-start content', () => {
|
||||
it('renders a heading with app name', () => {
|
||||
render(<WelcomeDialog />);
|
||||
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 () => {
|
||||
@@ -25,33 +83,24 @@ describe('WelcomeDialog', () => {
|
||||
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 />);
|
||||
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 }));
|
||||
expect(useSettingsStore.getState().welcomeDismissed).toBe(true);
|
||||
});
|
||||
|
||||
// Regression guard: Radix Dialog emits a console.warn when the Content's
|
||||
// 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(' '));
|
||||
it('quick start buttons dispatch commands and close', async () => {
|
||||
render(<WelcomeDialog />);
|
||||
await userEvent.click(screen.getByText('New File'));
|
||||
expect(useCommandStore.getState().handlers['file.new']).toBeDefined();
|
||||
expect(useAppStore.getState().modal).toEqual({ kind: null });
|
||||
});
|
||||
|
||||
it('fetches and displays version number', async () => {
|
||||
render(<WelcomeDialog />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/v1\.2\.3/)).toBeInTheDocument();
|
||||
});
|
||||
try {
|
||||
render(<WelcomeDialog />);
|
||||
// Effects that fire the warning run in a microtask; one tick is enough.
|
||||
await Promise.resolve();
|
||||
const offending = warnings.filter((w) =>
|
||||
/Missing\s+`?Description`?|aria-describedby=\{undefined\}/.test(w)
|
||||
);
|
||||
expect(offending).toEqual([]);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,11 @@ import { useEditorStore } from '@/stores/editor-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
vi.mock('@/lib/docx-export', () => ({
|
||||
generateDocx: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
})
|
||||
),
|
||||
generateDocx: vi.fn().mockResolvedValue(
|
||||
new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
})
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/toast', () => ({
|
||||
|
||||
@@ -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';
|
||||
|
||||
const mockGitStage = vi.fn();
|
||||
const mockGitCommit = vi.fn();
|
||||
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
value: {
|
||||
gitStage: mockGitStage,
|
||||
gitCommit: mockGitCommit,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('GitStatusPanel', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -54,7 +76,6 @@ describe('GitStatusPanel', () => {
|
||||
});
|
||||
useFileStore.setState({ rootPath: '/project' } as any);
|
||||
render(<GitStatusPanel />);
|
||||
// The helper text appears in a <p> element distinct from the error heading
|
||||
expect(
|
||||
await screen.findByText('Not a git repository, or git not installed.')
|
||||
).toBeInTheDocument();
|
||||
@@ -72,4 +93,65 @@ describe('GitStatusPanel', () => {
|
||||
await userEvent.click(row);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Markdown Extensions', () => {
|
||||
tokens: [{ type: 'text', text: 'Be careful!' }],
|
||||
};
|
||||
const mockParser = {
|
||||
parse: jest.fn((tokens) => '<p>Be careful!</p>'),
|
||||
parse: jest.fn((_tokens) => '<p>Be careful!</p>'),
|
||||
};
|
||||
const context = { parser: mockParser };
|
||||
const html = extension.renderer.call(context, token);
|
||||
|
||||
@@ -8,7 +8,6 @@ describe('PDFOperations Utilities', () => {
|
||||
describe('page range parsing', () => {
|
||||
it('should parse single page numbers', () => {
|
||||
// Test logic: parsing "1" should extract page index 0
|
||||
const input = '1';
|
||||
const pages = [0]; // Parsed result
|
||||
|
||||
expect(pages.length).toBe(1);
|
||||
@@ -17,7 +16,6 @@ describe('PDFOperations Utilities', () => {
|
||||
|
||||
it('should parse page ranges', () => {
|
||||
// Test logic: parsing "1-3" should extract pages 0, 1, 2
|
||||
const input = '1-3';
|
||||
const pages = [0, 1, 2]; // Expected result
|
||||
|
||||
expect(pages.length).toBe(3);
|
||||
@@ -26,7 +24,6 @@ describe('PDFOperations Utilities', () => {
|
||||
|
||||
it('should handle multiple ranges', () => {
|
||||
// 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
|
||||
|
||||
expect(pages.length).toBe(4);
|
||||
|
||||
@@ -80,8 +80,6 @@ describe('Security: Path Handling', () => {
|
||||
|
||||
describe('validation patterns', () => {
|
||||
it('should validate path existence check pattern', () => {
|
||||
const validationPattern = /^[a-zA-Z0-9._\-/]+$/;
|
||||
|
||||
const validPaths = ['documents/file.md', 'folder_2026/data.csv'];
|
||||
validPaths.forEach((pathStr) => {
|
||||
// These should match a reasonable filename pattern
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ global.window.DOMPurify = {
|
||||
|
||||
// Mock highlight.js
|
||||
global.window.hljs = {
|
||||
highlight: jest.fn((code, options) => ({ value: code })),
|
||||
highlight: jest.fn((code, _options) => ({ value: code })),
|
||||
highlightAuto: jest.fn((code) => ({ value: code })),
|
||||
getLanguage: jest.fn(() => true),
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('SidebarManager', () => {
|
||||
});
|
||||
|
||||
test('starts collapsed', () => {
|
||||
const mgr = new SidebarManager();
|
||||
new SidebarManager();
|
||||
expect(document.getElementById('sidebar').classList.contains('collapsed')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -70,4 +70,25 @@ describe('modal commands', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user