From f2398e6e1ab986084e53b13623bbd67badfbd991 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sat, 13 Jun 2026 19:34:42 +0530 Subject: [PATCH] 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 --- eslint.config.js | 2 +- src/main/GitOperations.js | 2 +- src/main/files/index.js | 2 +- src/main/index.js | 61 +- src/main/ipc/crash-handlers.js | 2 +- src/main/menu/items.js | 10 +- src/main/updater/crash-writer.js | 2 +- src/main/utils/paths.js | 4 +- src/main/word-template/index.js | 5 +- .../writing-studio/panels/proofread-panel.js | 2 +- src/plugins/settings-store.js | 2 +- src/preload.js | 1 + src/renderer/App.tsx | 2 + .../components/editor/CodeMirrorEditor.tsx | 104 +- src/renderer/components/editor/EditorPane.tsx | 8 +- .../components/editor/FindReplaceBar.tsx | 246 +++++ .../modals/BatchMediaConverterDialog.tsx | 290 +++++ .../components/modals/CommandPalette.tsx | 157 +++ .../components/modals/ExportDocxDialog.tsx | 63 +- .../components/modals/ExportHtmlDialog.tsx | 59 +- .../components/modals/ExportPdfDialog.tsx | 127 ++- .../components/modals/HeaderFooterDialog.tsx | 299 ++++++ src/renderer/components/modals/ModalLayer.tsx | 17 + .../components/modals/PdfEditorDialog.tsx | 988 ++++++++++++++++++ .../modals/UniversalConverterDialog.tsx | 386 +++++++ .../components/modals/WelcomeDialog.tsx | 295 +++++- .../components/sidebar/GitStatusPanel.tsx | 172 ++- .../lib/commands/register-menu-commands.ts | 32 +- src/renderer/lib/ipc.ts | 8 + src/renderer/stores/app-store.ts | 10 +- src/sidebar/explorer-panel.js | 4 +- src/sidebar/git-panel.js | 2 +- src/sidebar/templates-panel.js | 3 - .../modals/BatchMediaConverterDialog.test.tsx | 113 ++ .../component/modals/CommandPalette.test.tsx | 90 ++ .../modals/ExportBatchDialog.test.tsx | 10 +- .../modals/ExportDocxDialog.test.tsx | 89 +- .../modals/ExportHtmlDialog.test.tsx | 77 +- .../component/modals/ExportPdfDialog.test.tsx | 89 +- .../modals/HeaderFooterDialog.test.tsx | 91 ++ tests/component/modals/ModalLayer.test.tsx | 6 + .../component/modals/PdfEditorDialog.test.tsx | 297 ++++++ .../modals/UniversalConverterDialog.test.tsx | 89 ++ tests/component/modals/WelcomeDialog.test.tsx | 113 +- .../modals/WordExportDialog.test.tsx | 12 +- .../component/sidebar/GitStatusPanel.test.tsx | 84 +- tests/markdown-extensions.test.js | 2 +- tests/pdf-operations.test.js | 3 - tests/security-path-handling.test.js | 2 - tests/setup.js | 2 +- tests/sidebar.test.js | 2 +- tests/unit/commands/modal-commands.test.ts | 21 + 52 files changed, 4309 insertions(+), 250 deletions(-) create mode 100644 src/renderer/components/editor/FindReplaceBar.tsx create mode 100644 src/renderer/components/modals/BatchMediaConverterDialog.tsx create mode 100644 src/renderer/components/modals/CommandPalette.tsx create mode 100644 src/renderer/components/modals/HeaderFooterDialog.tsx create mode 100644 src/renderer/components/modals/PdfEditorDialog.tsx create mode 100644 src/renderer/components/modals/UniversalConverterDialog.tsx create mode 100644 tests/component/modals/BatchMediaConverterDialog.test.tsx create mode 100644 tests/component/modals/CommandPalette.test.tsx create mode 100644 tests/component/modals/HeaderFooterDialog.test.tsx create mode 100644 tests/component/modals/PdfEditorDialog.test.tsx create mode 100644 tests/component/modals/UniversalConverterDialog.test.tsx diff --git a/eslint.config.js b/eslint.config.js index 5c51f90..76004dc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 diff --git a/src/main/GitOperations.js b/src/main/GitOperations.js index f5c408e..60dac59 100644 --- a/src/main/GitOperations.js +++ b/src/main/GitOperations.js @@ -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' }; } } diff --git a/src/main/files/index.js b/src/main/files/index.js index 41abb31..3871eff 100644 --- a/src/main/files/index.js +++ b/src/main/files/index.js @@ -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'); diff --git a/src/main/index.js b/src/main/index.js index 7a7a481..75546ec 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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'], diff --git a/src/main/ipc/crash-handlers.js b/src/main/ipc/crash-handlers.js index 0b3c5aa..b01e80b 100644 --- a/src/main/ipc/crash-handlers.js +++ b/src/main/ipc/crash-handlers.js @@ -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(); }); diff --git a/src/main/menu/items.js b/src/main/menu/items.js index 94f8c4c..37e4524 100644 --- a/src/main/menu/items.js +++ b/src/main/menu/items.js @@ -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', diff --git a/src/main/updater/crash-writer.js b/src/main/updater/crash-writer.js index 26939b9..a4e595e 100644 --- a/src/main/updater/crash-writer.js +++ b/src/main/updater/crash-writer.js @@ -31,7 +31,7 @@ class CrashWriter { const oldest = files.shift(); try { fs.unlinkSync(path.join(this.dir, oldest)); - } catch (_) { + } catch (_unlinkErr) { /* ignore */ } } diff --git a/src/main/utils/paths.js b/src/main/utils/paths.js index 362a4a7..9821bfe 100644 --- a/src/main/utils/paths.js +++ b/src/main/utils/paths.js @@ -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' }; } diff --git a/src/main/word-template/index.js b/src/main/word-template/index.js index 267000e..60ac661 100644 --- a/src/main/word-template/index.js +++ b/src/main/word-template/index.js @@ -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 += ''; tableXml += ''; @@ -715,7 +714,7 @@ class WordTemplateExporter { */ parseInlineFormatting(text) { let xml = ''; - const pos = 0; + const _pos = 0; // Patterns for inline formatting const patterns = [ diff --git a/src/plugins/built-in/writing-studio/panels/proofread-panel.js b/src/plugins/built-in/writing-studio/panels/proofread-panel.js index f8619fc..671e2f1 100644 --- a/src/plugins/built-in/writing-studio/panels/proofread-panel.js +++ b/src/plugins/built-in/writing-studio/panels/proofread-panel.js @@ -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'], ]) { diff --git a/src/plugins/settings-store.js b/src/plugins/settings-store.js index 5e07150..d81b989 100644 --- a/src/plugins/settings-store.js +++ b/src/plugins/settings-store.js @@ -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. } diff --git a/src/preload.js b/src/preload.js index 5ba7628..eac3238 100644 --- a/src/preload.js +++ b/src/preload.js @@ -134,6 +134,7 @@ const ALLOWED_SEND_CHANNELS = [ 'app:quit', 'app:open-external', 'app:show-save-dialog', + 'get-app-version', // Updater 'updater:check', diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d350cb7..4a676f4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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() { <> + diff --git a/src/renderer/components/editor/CodeMirrorEditor.tsx b/src/renderer/components/editor/CodeMirrorEditor.tsx index c666899..cdfe9d6 100644 --- a/src/renderer/components/editor/CodeMirrorEditor.tsx +++ b/src/renderer/components/editor/CodeMirrorEditor.tsx @@ -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 = { + '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 { + const reader = new FileReader(); + reader.onload = async () => { + const base64 = (reader.result as string).split(',')[1]; + if (!base64) return; + const ext = guessExt(file.type); + const result = await window.electronAPI.savePastedImage({ base64, ext }); + if (result) { + insertSnippet(`![${file.name}](${result.relativePath})`); + toast.success('Image pasted'); + } + }; + reader.readAsDataURL(file); +} + +function createPasteHandler() { + return EditorView.domEventHandlers({ + paste(event: ClipboardEvent, _view: EditorView) { + const items = event.clipboardData?.items; + if (!items) return false; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (IMAGE_TYPES.has(item.type)) { + event.preventDefault(); + const file = item.getAsFile(); + if (file) handleImageFile(file); + return true; + } + } + return false; + }, + }); +} + export function CodeMirrorEditor({ bufferId, initialContent, onChange, onCursorChange }: Props) { const ref = useRef(null); const viewRef = useRef(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 (
-
+
{minimap && ( - + )}
); diff --git a/src/renderer/components/editor/EditorPane.tsx b/src/renderer/components/editor/EditorPane.tsx index b374ee2..605e465 100644 --- a/src/renderer/components/editor/EditorPane.tsx +++ b/src/renderer/components/editor/EditorPane.tsx @@ -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 ( -
- +
+ +
+ +
); } diff --git a/src/renderer/components/editor/FindReplaceBar.tsx b/src/renderer/components/editor/FindReplaceBar.tsx new file mode 100644 index 0000000..65aeab0 --- /dev/null +++ b/src/renderer/components/editor/FindReplaceBar.tsx @@ -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(null); + const replaceRef = useRef(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) => { + 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) => { + 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 ( +
+ { + 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" + /> + + + + + + { + 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" + /> + + + + + + +
+ ); +} diff --git a/src/renderer/components/modals/BatchMediaConverterDialog.tsx b/src/renderer/components/modals/BatchMediaConverterDialog.tsx new file mode 100644 index 0000000..24280a0 --- /dev/null +++ b/src/renderer/components/modals/BatchMediaConverterDialog.tsx @@ -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 = { + 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 = { + imagemagick: 'ImageMagick', + ffmpeg: 'FFmpeg', +}; + +export function BatchMediaConverterDialog() { + const closeModal = useAppStore((s) => s.closeModal); + const [tool, setTool] = useState('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(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 ( + !o && closeModal()}> + + + Batch Media Converter + + Convert multiple media files between formats using ImageMagick or FFmpeg + + +
+ setTool(v as ToolKey)}> + + ImageMagick + FFmpeg + + +
+
+ + +
+
+ + +
+
+ +
+ setInputFolder(e.target.value)} + placeholder="Input folder" + className="flex-1" + aria-label="Input folder" + /> + +
+
+ setOutputFolder(e.target.value)} + placeholder="Output folder" + className="flex-1" + aria-label="Output folder" + /> + +
+ +
+ + +
+
+
+ + {converting && ( +
+
+
+
+

{progress}%

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + +
+ ); +} diff --git a/src/renderer/components/modals/CommandPalette.tsx b/src/renderer/components/modals/CommandPalette.tsx new file mode 100644 index 0000000..0adb2fc --- /dev/null +++ b/src/renderer/components/modals/CommandPalette.tsx @@ -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(null); + const listRef = useRef(null); + + const commands = useMemo(() => { + 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 ( +
+
setOpen(false)} /> +
+
+ 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 + } + /> +
+
+ {filtered.length === 0 && ( +
+ No matching commands +
+ )} + {filtered.map((cmd, idx) => ( + + ))} +
+
+
+ ); +} diff --git a/src/renderer/components/modals/ExportDocxDialog.tsx b/src/renderer/components/modals/ExportDocxDialog.tsx index d6eca12..2915303 100644 --- a/src/renderer/components/modals/ExportDocxDialog.tsx +++ b/src/renderer/components/modals/ExportDocxDialog.tsx @@ -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(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 ( !o && closeModal()}> - + Export to DOCX {sourcePath} @@ -99,6 +117,49 @@ export function ExportDocxDialog({ sourcePath }: { sourcePath: string }) { /> Render tables as ASCII +
+ + setReferenceDoc(e.target.value)} + placeholder="/path/to/reference.docx" + aria-label="Reference document path" + /> +
+
+ + +
+ {toc && ( +
+ + setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))} + className="w-20" + aria-label="TOC depth" + /> +
+ )} +
+ + +
+
+ + setBibliography(e.target.value)} + placeholder="/path/to/references.bib" + aria-label="Bibliography file path" + /> +
{error && (
(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 ( !o && closeModal()}> - + Export to HTML {sourcePath} @@ -103,6 +123,10 @@ export function ExportHtmlDialog({ sourcePath }: { sourcePath: string }) {
+
+ + +
+
+ + +
+ {toc && ( +
+ + setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))} + className="w-20" + aria-label="TOC depth" + /> +
+ )} +
+ + +
+
+ + setCssPath(e.target.value)} + placeholder="/path/to/custom.css" + aria-label="Custom CSS file path" + /> +
{error && (
(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(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('', `${pageCss}`); - 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 ( !o && closeModal()}> - + Export to PDF {sourcePath} @@ -135,6 +161,99 @@ export function ExportPdfDialog({ sourcePath }: { sourcePath: string }) { /> Render tables as ASCII +
+ + +
+
+ + +
+ {toc && ( +
+ + setTocDepth(Math.min(6, Math.max(1, Number(e.target.value))))} + className="w-20" + aria-label="TOC depth" + /> +
+ )} +
+ + +
+
+ + +
+
+ + setBibliography(e.target.value)} + placeholder="/path/to/references.bib" + aria-label="Bibliography file path" + /> +
+
+ + setMainFont(e.target.value)} + placeholder="e.g. Latin Modern" + aria-label="Main font" + /> +
+
+ + setCjkFont(e.target.value)} + placeholder="e.g. Noto Sans CJK SC" + aria-label="CJK font" + /> +
+
+ + +
{error && (
; + +export function HeaderFooterDialog() { + const closeModal = useAppStore((s) => s.closeModal); + const [settings, setSettings] = useState(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 ( + !o && closeModal()}> + + + Header & Footer + + Configure headers and footers for exported documents + + +
+ + +
+

Header

+ {fieldRows + .filter((r) => r.key.startsWith('header')) + .map((row) => ( +
+ {row.label} + updateField(row.key, e.target.value)} + placeholder={`Header ${row.label.toLowerCase()}`} + className="flex-1" + disabled={!settings.headerEnabled} + aria-label={`Header ${row.label.toLowerCase()}`} + /> +
+ {DYNAMIC_FIELDS.slice(0, 4).map((field) => ( + + ))} +
+
+ ))} +
+ + + +
+

Footer

+ {fieldRows + .filter((r) => r.key.startsWith('footer')) + .map((row) => ( +
+ {row.label} + updateField(row.key, e.target.value)} + placeholder={`Footer ${row.label.toLowerCase()}`} + className="flex-1" + disabled={!settings.footerEnabled} + aria-label={`Footer ${row.label.toLowerCase()}`} + /> +
+ {DYNAMIC_FIELDS.slice(0, 4).map((field) => ( + + ))} +
+
+ ))} +
+ +
+

Logo

+
+ + {settings.logoPosition !== 'none' && ( + <> + + + {settings.logoPath && ( + + )} + + )} +
+ {settings.logoPath && ( + Logo preview { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/renderer/components/modals/ModalLayer.tsx b/src/renderer/components/modals/ModalLayer.tsx index a1191f3..78c0fab 100644 --- a/src/renderer/components/modals/ModalLayer.tsx +++ b/src/renderer/components/modals/ModalLayer.tsx @@ -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 ; case 'writing-analytics': return ; + case 'pdf-editor': + return ( + + ); + case 'universal-converter': + return ; + case 'header-footer': + return ; + case 'batch-media-converter': + return ; } } diff --git a/src/renderer/components/modals/PdfEditorDialog.tsx b/src/renderer/components/modals/PdfEditorDialog.tsx new file mode 100644 index 0000000..a1ec9d0 --- /dev/null +++ b/src/renderer/components/modals/PdfEditorDialog.tsx @@ -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: , + needsInput: false, + needsOutput: true, + needsFolder: false, + multiInput: true, + }, + { + id: 'split', + label: 'Split', + icon: , + needsInput: true, + needsOutput: false, + needsFolder: true, + multiInput: false, + }, + { + id: 'compress', + label: 'Compress', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'rotate', + label: 'Rotate', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'delete', + label: 'Delete', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'reorder', + label: 'Reorder', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'watermark', + label: 'Watermark', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'encrypt', + label: 'Encrypt', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'decrypt', + label: 'Decrypt', + icon: , + needsInput: true, + needsOutput: true, + needsFolder: false, + multiInput: false, + }, + { + id: 'permissions', + label: 'Permissions', + icon: , + 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('merge'); + const [submitting, setSubmitting] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const [inputPath, setInputPath] = useState(initialFilePath ?? ''); + const [outputPath, setOutputPath] = useState(''); + const [outputFolder, setOutputFolder] = useState(''); + const [mergeFiles, setMergeFiles] = useState([]); + + const [pages, setPages] = useState(''); + const [rotateAngle, setRotateAngle] = useState('90'); + const [newOrder, setNewOrder] = useState(''); + + const [splitMode, setSplitMode] = useState('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>({}); + + const [decryptPassword, setDecryptPassword] = useState(''); + + const [permissionsPassword, setPermissionsPassword] = useState(''); + const [permissions, setPermissions] = useState>({}); + + 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 => { + const r = await window.electronAPI?.file?.pickFile(); + return r?.data ?? null; + }; + + const pickFolder = async (): Promise => { + 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 => { + const base = { operation: activeTab }; + switch (activeTab) { + case 'merge': + return { ...base, inputPaths: mergeFiles, outputPath }; + case 'split': { + const splitData: Record = { + ...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 = '' + ) => ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ); + + const renderFolderField = ( + label: string, + value: string, + onChange: (v: string) => void, + onPick: () => void, + placeholder = '' + ) => ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ); + + const renderError = () => + error ? ( +
+ {error} +
+ ) : null; + + const renderProgress = () => + progress ? ( +
+ + {progress} +
+ ) : null; + + return ( + !o && onClose()}> + + + PDF Editor + Manipulate and transform PDF files + + + { + setActiveTab(v as Operation); + setError(null); + }} + > + + {OPERATIONS.map((op) => ( + + {op.icon} + {op.label} + + ))} + + +
+ +
+ +
+ {mergeFiles.map((f, i) => ( +
+ {f} + +
+ ))} + +
+
+ {renderFileField( + 'Output path', + outputPath, + setOutputPath, + handlePickOutput, + 'output.pdf' + )} + {renderError()} +
+ + + {renderFileField( + 'Input PDF', + inputPath, + setInputPath, + handlePickInput, + 'Select input PDF' + )} + {renderFolderField( + 'Output folder', + outputFolder, + setOutputFolder, + handlePickFolder, + 'Select output folder' + )} +
+ + +
+ {splitMode === 'ranges' && ( +
+ + setPageRanges(e.target.value)} + placeholder="1-3,5,7-10" + /> +
+ )} + {splitMode === 'interval' && ( +
+ + setSplitInterval(e.target.value)} + placeholder="5" + /> +
+ )} + {splitMode === 'size' && ( +
+ + setSplitSize(e.target.value)} + placeholder="1" + /> +
+ )} + {renderError()} +
+ + + {renderFileField( + 'Input PDF', + inputPath, + setInputPath, + handlePickInput, + 'Select input PDF' + )} + {renderFileField( + 'Output path', + outputPath, + setOutputPath, + handlePickOutput, + 'output.pdf' + )} +
+ + setCompressLevel(v[0])} + min={0} + max={100} + step={5} + aria-label="Compression level" + /> +
+ Low quality / small size + High quality / large size +
+
+
+ + + +
+ {renderError()} +
+ + + {renderFileField( + 'Input PDF', + inputPath, + setInputPath, + handlePickInput, + 'Select input PDF' + )} + {renderFileField( + 'Output path', + outputPath, + setOutputPath, + handlePickOutput, + 'output.pdf' + )} +
+ + setPages(e.target.value)} + placeholder="All pages" + /> +
+
+ + +
+ {renderError()} +
+ + + {renderFileField( + 'Input PDF', + inputPath, + setInputPath, + handlePickInput, + 'Select input PDF' + )} + {renderFileField( + 'Output path', + outputPath, + setOutputPath, + handlePickOutput, + 'output.pdf' + )} +
+ + setPages(e.target.value)} + placeholder="1,3,5-8" + /> +
+ {renderError()} +
+ + + {renderFileField( + 'Input PDF', + inputPath, + setInputPath, + handlePickInput, + 'Select input PDF' + )} + {renderFileField( + 'Output path', + outputPath, + setOutputPath, + handlePickOutput, + 'output.pdf' + )} +
+ +