fix: improve tab safety and app feedback

This commit is contained in:
2026-04-14 22:30:46 +05:30
parent b7e12f7010
commit 4426b75c6f
15 changed files with 417 additions and 70 deletions
+25
View File
@@ -0,0 +1,25 @@
# Repository Guidelines
## Project Structure & Module Organization
Core application code lives in `src/`. Use `src/main.js` for the Electron main process, `src/preload.js` for the preload bridge, and `src/renderer.js` plus `src/editor/`, `src/sidebar/`, `src/repl/`, and `src/utils/` for renderer-side features. Electron adapter code is in `src/adapters/electron/`. Reusable markdown/document templates live in `src/templates/`. Static assets and icons are in `assets/`. Tests are in `tests/`, and build output goes to `dist/`.
## Build, Test, and Development Commands
- `npm start`: launch the Electron app locally.
- `npm test`: run the Jest suite once.
- `npm run test:watch`: rerun tests during local development.
- `npm run test:coverage`: generate coverage output.
- `npm run lint` / `npm run lint:fix`: check or fix ESLint issues in `src` and `tests`.
- `npm run format` / `npm run format:check`: apply or verify Prettier formatting.
- `npm run build:linux`, `npm run build:win`, `npm run build:mac`: create platform packages with `electron-builder`.
## Coding Style & Naming Conventions
This repo uses Prettier and ESLint. Follow `.prettierrc`: 2-space indentation, single quotes, semicolons, trailing commas where valid in ES5, and a 100-character line width. Prefer `camelCase` for variables/functions, `PascalCase` for classes, and kebab-case for file names only when already established. Keep module boundaries clear: UI logic in renderer modules, OS/file-system work behind Electron IPC and adapters.
## Testing Guidelines
Tests use Jest with `jest-environment-jsdom`. Add new tests under `tests/` with `*.test.js` names, mirroring the feature area when possible, for example `tests/sidebar.test.js` or `tests/print-preview.test.js`. Update or add regression tests for renderer behavior, preload APIs, and utility helpers when fixing bugs. Run `npm test` before opening a PR; use `npm run test:coverage` for larger refactors.
## Commit & Pull Request Guidelines
Recent history follows Conventional Commit prefixes such as `feat:`, `fix:`, and `refactor:`. Keep subjects short and imperative, for example `fix: guard modal cleanup on close`. PRs should describe the user-visible change, note test coverage, link any related issue, and include screenshots or GIFs for UI changes.
## Security & Configuration Tips
Do not bypass preload boundaries or introduce direct `eval`/dynamic code paths; ESLint already treats these as errors. Export and conversion features depend on external tools such as Pandoc, FFmpeg, ImageMagick, and LibreOffice, so document any new runtime dependency in `README.md` and packaging config.
+1 -1
View File
@@ -162,4 +162,4 @@ Amit Haridas (amit.wh@gmail.com)
## Version ## Version
v3.0.0 v4.1.0
+1 -1
View File
@@ -1,6 +1,6 @@
# MarkdownConverter - STRIDE Threat Model Analysis # MarkdownConverter - STRIDE Threat Model Analysis
**Version:** 4.0.0 **Version:** 4.1.0
**Date:** 2026-03-15 **Date:** 2026-03-15
**Methodology:** STRIDE + MITRE ATT&CK Mapping **Methodology:** STRIDE + MITRE ATT&CK Mapping
**Analyst:** Security Assessment Team **Analyst:** Security Assessment Team
+20 -16
View File
@@ -18,7 +18,7 @@ const electronFsAdapter = {
* @returns {Promise<string>} File content * @returns {Promise<string>} File content
*/ */
async readFile(path) { async readFile(path) {
return await window.electronAPI.readFile(path); return await window.electronAPI.file.read(path);
}, },
/** /**
@@ -28,7 +28,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async writeFile(path, content) { async writeFile(path, content) {
return await window.electronAPI.writeFile(path, content); return await window.electronAPI.file.write(path, content);
}, },
/** /**
@@ -37,8 +37,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async deleteFile(path) { async deleteFile(path) {
// TODO: Add IPC channel for delete return await window.electronAPI.file.delete(path);
throw new Error('deleteFile not implemented');
}, },
/** /**
@@ -47,8 +46,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async ensureDir(path) { async ensureDir(path) {
// TODO: Add IPC channel for ensureDir return await window.electronAPI.file.ensureDir(path);
throw new Error('ensureDir not implemented');
}, },
/** /**
@@ -57,8 +55,18 @@ const electronFsAdapter = {
* @returns {Promise<Array<import('../types').FileInfo>>} * @returns {Promise<Array<import('../types').FileInfo>>}
*/ */
async listDirectory(path) { async listDirectory(path) {
// TODO: Add IPC channel for listDirectory const result = await window.electronAPI.invoke('list-directory', path);
throw new Error('listDirectory not implemented'); if (!result?.entries) {
return [];
}
return result.entries.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
size: entry.size ?? 0,
modified: entry.modified ?? 0,
path: entry.path
}));
}, },
/** /**
@@ -67,8 +75,7 @@ const electronFsAdapter = {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async exists(path) { async exists(path) {
// TODO: Add IPC channel for exists return await window.electronAPI.file.exists(path);
throw new Error('exists not implemented');
}, },
/** /**
@@ -77,8 +84,7 @@ const electronFsAdapter = {
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
async isDirectory(path) { async isDirectory(path) {
// TODO: Add IPC channel for isDirectory return await window.electronAPI.file.isDirectory(path);
throw new Error('isDirectory not implemented');
}, },
/** /**
@@ -88,8 +94,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async copy(source, dest) { async copy(source, dest) {
// TODO: Add IPC channel for copy return await window.electronAPI.file.copy(source, dest);
throw new Error('copy not implemented');
}, },
/** /**
@@ -99,8 +104,7 @@ const electronFsAdapter = {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async move(source, dest) { async move(source, dest) {
// TODO: Add IPC channel for move return await window.electronAPI.file.move(source, dest);
throw new Error('move not implemented');
} }
}; };
+2
View File
@@ -1275,6 +1275,8 @@
</div> </div>
</div> </div>
<div id="pdf-status-message" class="info-message hidden" aria-live="polite"></div>
<!-- Progress indicator --> <!-- Progress indicator -->
<div id="pdf-progress" class="batch-progress hidden"> <div id="pdf-progress" class="batch-progress hidden">
<div class="progress-bar"> <div class="progress-bar">
+136 -4
View File
@@ -124,6 +124,35 @@ function validatePath(filePath) {
return { valid: true, resolved }; return { valid: true, resolved };
} }
/**
* Resolves a path for operations where the target may not exist yet.
* Validates string shape and blocks obviously sensitive locations.
* @param {string} filePath
* @returns {{ valid: boolean, resolved: string, error?: string }}
*/
function resolveWritablePath(filePath) {
if (!filePath || typeof filePath !== 'string') {
return { valid: false, resolved: '', error: 'Invalid path' };
}
let resolved;
try {
resolved = path.normalize(path.resolve(filePath));
} catch (err) {
return { valid: false, resolved: '', error: 'Invalid path format' };
}
if (resolved.includes('\0')) {
return { valid: false, resolved: '', error: 'Null byte in path' };
}
if (!isPathAccessible(resolved)) {
return { valid: false, resolved, error: 'Path is not accessible' };
}
return { valid: true, resolved };
}
/** /**
* Checks if a resolved path is within allowed directories * Checks if a resolved path is within allowed directories
* For an editor app, we allow access to all user-accessible paths * For an editor app, we allow access to all user-accessible paths
@@ -1000,7 +1029,7 @@ function showAboutDialog() {
<body> <body>
<img src="${iconBase64}" class="logo" alt="MarkdownConverter"> <img src="${iconBase64}" class="logo" alt="MarkdownConverter">
<h1>MarkdownConverter</h1> <h1>MarkdownConverter</h1>
<div class="version">Version 4.0.0</div> <div class="version">Version 4.1.0</div>
<div class="company"> <div class="company">
<span>by</span> <span>by</span>
@@ -2793,9 +2822,13 @@ ipcMain.on('save-file', (event, { path, content }) => {
currentFile = path; currentFile = path;
}); });
ipcMain.on('save-current-file', (event, content) => { ipcMain.on('save-current-file', (event, payload) => {
if (currentFile) { const content = typeof payload === 'string' ? payload : payload?.content;
fs.writeFileSync(currentFile, content, 'utf-8'); const targetFile = typeof payload === 'string' ? currentFile : payload?.filePath || currentFile;
if (targetFile) {
fs.writeFileSync(targetFile, content, 'utf-8');
currentFile = targetFile;
} else { } else {
saveAsFile(); saveAsFile();
} }
@@ -4344,6 +4377,8 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
.map(e => ({ .map(e => ({
name: e.name, name: e.name,
isDirectory: e.isDirectory(), isDirectory: e.isDirectory(),
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
path: path.join(validation.resolved, e.name) path: path.join(validation.resolved, e.name)
})); }));
return { path: validation.resolved, entries }; return { path: validation.resolved, entries };
@@ -4353,6 +4388,103 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
} }
}); });
ipcMain.handle('read-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
return fs.readFileSync(validation.resolved, 'utf-8');
});
ipcMain.handle('write-file', async (event, payload) => {
const validation = resolveWritablePath(payload?.path);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid file path');
}
fs.mkdirSync(path.dirname(validation.resolved), { recursive: true });
fs.writeFileSync(validation.resolved, payload?.content ?? '', 'utf-8');
return { path: validation.resolved };
});
ipcMain.handle('delete-file', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
throw new Error(validation.error || 'Invalid file path');
}
fs.rmSync(validation.resolved, { recursive: true, force: false });
return true;
});
ipcMain.handle('ensure-directory', async (event, dirPath) => {
const validation = resolveWritablePath(dirPath);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid directory path');
}
fs.mkdirSync(validation.resolved, { recursive: true });
return validation.resolved;
});
ipcMain.handle('path-exists', async (event, filePath) => {
const validation = resolveWritablePath(filePath);
return validation.valid ? fs.existsSync(validation.resolved) : false;
});
ipcMain.handle('is-directory', async (event, filePath) => {
const validation = validatePath(filePath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
return false;
}
return fs.statSync(validation.resolved).isDirectory();
});
ipcMain.handle('copy-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
ipcMain.handle('move-path', async (event, payload) => {
const sourceValidation = validatePath(payload?.source);
const destinationValidation = resolveWritablePath(payload?.destination);
if (!sourceValidation.valid || !isPathAccessible(sourceValidation.resolved)) {
throw new Error(sourceValidation.error || 'Invalid source path');
}
if (!destinationValidation.valid) {
throw new Error(destinationValidation.error || 'Invalid destination path');
}
fs.mkdirSync(path.dirname(destinationValidation.resolved), { recursive: true });
try {
fs.renameSync(sourceValidation.resolved, destinationValidation.resolved);
} catch (error) {
if (error.code !== 'EXDEV') {
throw error;
}
fs.cpSync(sourceValidation.resolved, destinationValidation.resolved, { recursive: true });
fs.rmSync(sourceValidation.resolved, { recursive: true, force: false });
}
return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
});
// Open a file by path (from explorer panel) // Open a file by path (from explorer panel)
ipcMain.on('open-file-path', (event, filePath) => { ipcMain.on('open-file-path', (event, filePath) => {
try { try {
+18 -2
View File
@@ -9,7 +9,7 @@
* - All IPC channels are explicitly whitelisted * - All IPC channels are explicitly whitelisted
* - Prevents XSS from escalating to full system access * - Prevents XSS from escalating to full system access
* *
* @version 4.0.0 * @version 4.1.0
*/ */
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
@@ -101,6 +101,14 @@ const ALLOWED_SEND_CHANNELS = [
// File Explorer // File Explorer
'list-directory', 'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
// Git // Git
'git-status', 'git-status',
@@ -321,7 +329,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath), setCurrent: (filePath) => ipcRenderer.send('set-current-file', filePath),
saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles), saveRecent: (recentFiles) => ipcRenderer.send('save-recent-files', recentFiles),
clearRecent: () => ipcRenderer.send('clear-recent-files'), clearRecent: () => ipcRenderer.send('clear-recent-files'),
rendererReady: () => ipcRenderer.send('renderer-ready') rendererReady: () => ipcRenderer.send('renderer-ready'),
read: (filePath) => ipcRenderer.invoke('read-file', filePath),
write: (filePath, content) => ipcRenderer.invoke('write-file', { path: filePath, content }),
delete: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-directory', dirPath),
exists: (filePath) => ipcRenderer.invoke('path-exists', filePath),
isDirectory: (filePath) => ipcRenderer.invoke('is-directory', filePath),
copy: (source, destination) => ipcRenderer.invoke('copy-path', { source, destination }),
move: (source, destination) => ipcRenderer.invoke('move-path', { source, destination })
}, },
// Theme Operations // Theme Operations
+142 -38
View File
@@ -1,6 +1,6 @@
/** /**
* MarkdownConverter Renderer Process * MarkdownConverter Renderer Process
* @version 3.0.0 * @version 4.1.0
*/ */
const { ipcRenderer } = require('electron'); const { ipcRenderer } = require('electron');
@@ -31,6 +31,45 @@ function getZenMode() { if (!_ZenMode) _ZenMode = require('./zen-mode').ZenMode;
let _showAnalyticsModal; let _showAnalyticsModal;
function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; } function getShowAnalyticsModal() { if (!_showAnalyticsModal) _showAnalyticsModal = require('./analytics/analytics-panel').showAnalyticsModal; return _showAnalyticsModal; }
function ensureToastContainer() {
let container = document.getElementById('app-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'app-toast-container';
container.className = 'app-toast-container';
document.body.appendChild(container);
}
return container;
}
function notifyUser(message, type = 'info', options = {}) {
if (!message) return;
const { duration = 3500 } = options;
const container = ensureToastContainer();
const toast = document.createElement('div');
toast.className = `app-toast app-toast-${type}`;
toast.setAttribute('role', type === 'warning' ? 'alert' : 'status');
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('visible');
});
const dismiss = () => {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 180);
};
if (duration > 0) {
setTimeout(dismiss, duration);
}
toast.addEventListener('click', dismiss);
return toast;
}
// Configure marked with highlight extension // Configure marked with highlight extension
marked.use(markedHighlight({ marked.use(markedHighlight({
highlight: function(code, lang) { highlight: function(code, lang) {
@@ -357,7 +396,7 @@ class TabManager {
} catch (error) { } catch (error) {
console.error('Error loading PDF:', error); console.error('Error loading PDF:', error);
document.getElementById('status-text').textContent = 'Error loading PDF'; document.getElementById('status-text').textContent = 'Error loading PDF';
alert('Error loading PDF: ' + error.message); notifyUser(`Error loading PDF: ${error.message}`, 'warning');
} }
} }
@@ -457,9 +496,7 @@ class TabManager {
// Notify main process about current file for exports // Notify main process about current file for exports
const tab = this.tabs.get(tabId); const tab = this.tabs.get(tabId);
if (tab?.filePath) { ipcRenderer.send('set-current-file', tab?.filePath || null);
ipcRenderer.send('set-current-file', tab.filePath);
}
// Refresh outline panel for new tab content // Refresh outline panel for new tab content
if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline(); if (outlinePanelContainer?._refreshOutline) outlinePanelContainer._refreshOutline();
@@ -1729,7 +1766,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Auto-save logic for all tabs // Auto-save logic for all tabs
tabManager.tabs.forEach(tab => { tabManager.tabs.forEach(tab => {
if (tab.isDirty && tab.filePath) { if (tab.isDirty && tab.filePath) {
ipcRenderer.send('save-current-file', tab.content); ipcRenderer.send('save-current-file', { filePath: tab.filePath, content: tab.content });
} }
}); });
}, 30000); }, 30000);
@@ -1753,7 +1790,7 @@ ipcRenderer.on('file-save', () => {
const currentContent = tabManager.getCurrentContent(); const currentContent = tabManager.getCurrentContent();
const currentFilePath = tabManager.getCurrentFilePath(); const currentFilePath = tabManager.getCurrentFilePath();
// send to main process which will save or trigger save-as dialog // send to main process which will save or trigger save-as dialog
ipcRenderer.send('save-current-file', currentContent); ipcRenderer.send('save-current-file', { filePath: currentFilePath, content: currentContent });
}); });
ipcRenderer.on('get-content-for-save', (event, filePath) => { ipcRenderer.on('get-content-for-save', (event, filePath) => {
@@ -1870,7 +1907,7 @@ function openPrintPreviewDialog() {
const previewContent = document.getElementById(`preview-${activeTabId}`); const previewContent = document.getElementById(`preview-${activeTabId}`);
if (!previewContent || !previewContent.innerHTML.trim()) { if (!previewContent || !previewContent.innerHTML.trim()) {
alert('Nothing to print. Please create or open a document and ensure the preview is visible.'); notifyUser('Nothing to print. Create or open a document and keep the preview visible.', 'warning');
return; return;
} }
@@ -2112,7 +2149,7 @@ function saveCurrentProfile() {
// Select the newly created profile // Select the newly created profile
document.getElementById('export-profile-select').value = profileName; document.getElementById('export-profile-select').value = profileName;
alert(`Profile "${profileName}" saved successfully!`); notifyUser(`Profile "${profileName}" saved successfully.`, 'success');
} }
function loadProfile(profileName) { function loadProfile(profileName) {
@@ -2152,7 +2189,7 @@ function deleteSelectedProfile() {
const profileName = select.value; const profileName = select.value;
if (!profileName) { if (!profileName) {
alert('Please select a profile to delete.'); notifyUser('Select a profile to delete.', 'warning');
return; return;
} }
@@ -2161,7 +2198,7 @@ function deleteSelectedProfile() {
saveExportProfiles(); saveExportProfiles();
populateProfileDropdown(); populateProfileDropdown();
select.value = ''; select.value = '';
alert(`Profile "${profileName}" deleted successfully!`); notifyUser(`Profile "${profileName}" deleted successfully.`, 'success');
} }
} }
@@ -2855,7 +2892,8 @@ document.addEventListener('DOMContentLoaded', () => {
const includeSubfolders = document.getElementById('converter-batch-subfolders').checked; const includeSubfolders = document.getElementById('converter-batch-subfolders').checked;
if (!inputFolder || !outputFolder) { if (!inputFolder || !outputFolder) {
alert('Please select both input and output folders for batch conversion'); document.getElementById('converter-progress').classList.remove('hidden');
document.getElementById('converter-status').textContent = 'Select both input and output folders for batch conversion.';
return; return;
} }
@@ -2877,7 +2915,8 @@ document.addEventListener('DOMContentLoaded', () => {
const filePath = converterFilePath; const filePath = converterFilePath;
if (!filePath) { if (!filePath) {
alert('Please select a file to convert'); document.getElementById('converter-progress').classList.remove('hidden');
document.getElementById('converter-status').textContent = 'Select a file to convert.';
return; return;
} }
@@ -3025,7 +3064,13 @@ function showPDFEditorDialog(operation, openedFilePath = null) {
function hidePDFEditorDialog() { function hidePDFEditorDialog() {
window.modals.pdfEditorModal.close(); window.modals.pdfEditorModal.close();
clearPDFStatus();
document.getElementById('pdf-progress').classList.add('hidden'); document.getElementById('pdf-progress').classList.add('hidden');
document.getElementById('pdf-progress-text').textContent = 'Processing...';
const progressFill = document.getElementById('pdf-progress-fill');
if (progressFill) {
progressFill.style.width = '0%';
}
currentPDFOperation = null; currentPDFOperation = null;
} }
@@ -3226,7 +3271,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadCurrentOrder.addEventListener('click', () => { loadCurrentOrder.addEventListener('click', () => {
const inputPath = document.getElementById('reorder-input-path').value; const inputPath = document.getElementById('reorder-input-path').value;
if (!inputPath) { if (!inputPath) {
alert('Please select a PDF file first'); showPDFValidationMessage('Select a PDF file before loading the current page order.', '#reorder-input-path');
return; return;
} }
// Request page count from main process // Request page count from main process
@@ -3243,7 +3288,7 @@ ipcRenderer.on('pdf-folder-selected', (event, { inputId, path }) => {
// Handle PDF page count response // Handle PDF page count response
ipcRenderer.on('pdf-page-count', (event, { count, error }) => { ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
if (error) { if (error) {
alert('Error reading PDF: ' + error); showPDFStatus(`Error reading PDF: ${error}`, 'warning');
return; return;
} }
@@ -3253,6 +3298,39 @@ ipcRenderer.on('pdf-page-count', (event, { count, error }) => {
document.getElementById('reorder-pages').value = currentOrder; document.getElementById('reorder-pages').value = currentOrder;
}); });
function getPDFStatusElement() {
return document.getElementById('pdf-status-message');
}
function showPDFStatus(message, type = 'info') {
const status = getPDFStatusElement();
if (!status) return;
status.textContent = message;
status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message');
status.classList.add(`${type}-message`);
}
function clearPDFStatus() {
const status = getPDFStatusElement();
if (!status) return;
status.textContent = '';
status.classList.remove('info-message', 'warning-message', 'success-message');
status.classList.add('hidden');
}
function showPDFValidationMessage(message, focusSelector = null) {
showPDFStatus(message, 'warning');
if (focusSelector) {
const field = document.querySelector(focusSelector);
if (field && typeof field.focus === 'function') {
field.focus();
}
}
}
// Process PDF Operation // Process PDF Operation
function processPDFOperation() { function processPDFOperation() {
const operation = currentPDFOperation; const operation = currentPDFOperation;
@@ -3262,13 +3340,13 @@ function processPDFOperation() {
switch (operation) { switch (operation) {
case 'merge': case 'merge':
if (mergeFilePaths.length < 2) { if (mergeFilePaths.length < 2) {
alert('Please add at least 2 PDF files to merge'); showPDFValidationMessage('Add at least 2 PDF files to merge first.', '#add-merge-file');
return; return;
} }
operationData.inputFiles = mergeFilePaths; operationData.inputFiles = mergeFilePaths;
operationData.outputPath = document.getElementById('merge-output-path').value.trim(); operationData.outputPath = document.getElementById('merge-output-path').value.trim();
if (!operationData.outputPath) { if (!operationData.outputPath) {
alert('Please select an output file path'); showPDFValidationMessage('Select an output file path.', '#merge-output-path');
return; return;
} }
break; break;
@@ -3279,7 +3357,7 @@ function processPDFOperation() {
operationData.splitMode = document.getElementById('split-mode').value; operationData.splitMode = document.getElementById('split-mode').value;
if (!operationData.inputPath || !operationData.outputFolder) { if (!operationData.inputPath || !operationData.outputFolder) {
alert('Please select input file and output folder'); showPDFValidationMessage('Select both an input PDF and an output folder.', '#split-input-path');
return; return;
} }
@@ -3302,7 +3380,10 @@ function processPDFOperation() {
operationData.optimizeFonts = document.getElementById('compress-optimize-fonts').checked; operationData.optimizeFonts = document.getElementById('compress-optimize-fonts').checked;
if (!operationData.inputPath || !operationData.outputPath) { if (!operationData.inputPath || !operationData.outputPath) {
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file paths')); showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#compress-input-path'
);
return; return;
} }
break; break;
@@ -3315,7 +3396,10 @@ function processPDFOperation() {
operationData.angle = parseInt(document.getElementById('rotate-angle').value); operationData.angle = parseInt(document.getElementById('rotate-angle').value);
if (!operationData.inputPath || !operationData.outputPath) { if (!operationData.inputPath || !operationData.outputPath) {
alert('Please select input file' + (operationData.overwrite ? '' : ' and output file')); showPDFValidationMessage(
'Select an input PDF' + (operationData.overwrite ? '.' : ' and an output file path.'),
'#rotate-input-path'
);
return; return;
} }
break; break;
@@ -3327,7 +3411,7 @@ function processPDFOperation() {
operationData.pages = document.getElementById('delete-pages').value.trim(); operationData.pages = document.getElementById('delete-pages').value.trim();
if (!operationData.inputPath || !operationData.outputPath || !operationData.pages) { if (!operationData.inputPath || !operationData.outputPath || !operationData.pages) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and pages to delete.', '#delete-input-path');
return; return;
} }
break; break;
@@ -3339,7 +3423,7 @@ function processPDFOperation() {
operationData.newOrder = document.getElementById('reorder-pages').value.trim(); operationData.newOrder = document.getElementById('reorder-pages').value.trim();
if (!operationData.inputPath || !operationData.outputPath || !operationData.newOrder) { if (!operationData.inputPath || !operationData.outputPath || !operationData.newOrder) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and new page order.', '#reorder-input-path');
return; return;
} }
break; break;
@@ -3360,7 +3444,7 @@ function processPDFOperation() {
} }
if (!operationData.inputPath || !operationData.outputPath || !operationData.text) { if (!operationData.inputPath || !operationData.outputPath || !operationData.text) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and watermark text.', '#watermark-input-path');
return; return;
} }
break; break;
@@ -3385,7 +3469,7 @@ function processPDFOperation() {
}; };
if (!operationData.inputPath || !operationData.outputPath || !operationData.userPassword) { if (!operationData.inputPath || !operationData.outputPath || !operationData.userPassword) {
alert('Please select file and enter a user password'); showPDFValidationMessage('Select a file, output path, and user password.', '#encrypt-input-path');
return; return;
} }
break; break;
@@ -3397,7 +3481,7 @@ function processPDFOperation() {
operationData.password = document.getElementById('decrypt-password').value; operationData.password = document.getElementById('decrypt-password').value;
if (!operationData.inputPath || !operationData.outputPath || !operationData.password) { if (!operationData.inputPath || !operationData.outputPath || !operationData.password) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and password.', '#decrypt-input-path');
return; return;
} }
break; break;
@@ -3421,21 +3505,26 @@ function processPDFOperation() {
}; };
if (!operationData.inputPath || !operationData.outputPath || !operationData.ownerPassword) { if (!operationData.inputPath || !operationData.outputPath || !operationData.ownerPassword) {
alert('Please fill in all required fields'); showPDFValidationMessage('Fill in the input file, output path, and owner password.', '#permissions-input-path');
return; return;
} }
break; break;
} }
clearPDFStatus();
// Show progress // Show progress
document.getElementById('pdf-progress').classList.remove('hidden'); document.getElementById('pdf-progress').classList.remove('hidden');
document.getElementById('pdf-progress-text').textContent = 'Processing PDF...'; document.getElementById('pdf-progress-text').textContent = 'Processing PDF...';
const progressFill = document.getElementById('pdf-progress-fill');
if (progressFill) {
progressFill.style.width = '10%';
}
// Send to main process // Send to main process
ipcRenderer.send('process-pdf-operation', operationData); ipcRenderer.send('process-pdf-operation', operationData);
} catch (error) { } catch (error) {
alert('Error: ' + error.message); showPDFStatus(`Error: ${error.message}`, 'warning');
console.error('PDF operation error:', error); console.error('PDF operation error:', error);
} }
} }
@@ -3445,10 +3534,14 @@ ipcRenderer.on('pdf-operation-complete', (event, { success, error, message }) =>
document.getElementById('pdf-progress').classList.add('hidden'); document.getElementById('pdf-progress').classList.add('hidden');
if (success) { if (success) {
alert(message || 'PDF operation completed successfully!'); showPDFStatus(message || 'PDF operation completed successfully.', 'success');
hidePDFEditorDialog(); setTimeout(() => {
if (window.modals?.pdfEditorModal?.isOpen()) {
hidePDFEditorDialog();
}
}, 800);
} else { } else {
alert('Error: ' + (error || 'PDF operation failed')); showPDFStatus(`Error: ${error || 'PDF operation failed'}`, 'warning');
} }
}); });
@@ -3780,13 +3873,13 @@ function insertGeneratedTable() {
const table = document.getElementById('table-preview').textContent; const table = document.getElementById('table-preview').textContent;
if (!table) { if (!table) {
alert('Please generate a table preview first'); notifyUser('Generate a table preview first.', 'warning');
return; return;
} }
// Insert table using CodeMirror // Insert table using CodeMirror
if (!tabManager) { if (!tabManager) {
alert('No active editor found'); notifyUser('No active editor found.', 'warning');
return; return;
} }
@@ -4254,13 +4347,13 @@ function insertASCIIArt() {
const asciiArt = document.getElementById('ascii-preview').textContent; const asciiArt = document.getElementById('ascii-preview').textContent;
if (!asciiArt || asciiArt === 'Select a template from the buttons above') { if (!asciiArt || asciiArt === 'Select a template from the buttons above') {
alert('Please generate ASCII art first'); notifyUser('Generate ASCII art first.', 'warning');
return; return;
} }
// Insert using CodeMirror // Insert using CodeMirror
if (!tabManager) { if (!tabManager) {
alert('No active editor found'); notifyUser('No active editor found.', 'warning');
return; return;
} }
@@ -4683,14 +4776,25 @@ document.addEventListener('DOMContentLoaded', () => {
// ============================================ // ============================================
ipcRenderer.on('show-image-tool', (event, tool) => { ipcRenderer.on('show-image-tool', (event, tool) => {
alert(`Image ${tool} tool requires ImageMagick to be installed.\n\nPlease install ImageMagick from: https://imagemagick.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Image ${tool} requires ImageMagick. Install it from imagemagick.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
ipcRenderer.on('show-audio-tool', (event, tool) => { ipcRenderer.on('show-audio-tool', (event, tool) => {
alert(`Audio ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Audio ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
ipcRenderer.on('show-video-tool', (event, tool) => { ipcRenderer.on('show-video-tool', (event, tool) => {
alert(`Video ${tool} tool requires FFmpeg to be installed.\n\nPlease install FFmpeg from: https://ffmpeg.org/\n\nThis feature will be available in a future update with built-in support.`); notifyUser(
`Video ${tool} requires FFmpeg. Install it from ffmpeg.org to enable this tool.`,
'info',
{ duration: 5000 }
);
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* ConcreteInfo Theme for MarkdownConverter * ConcreteInfo Theme for MarkdownConverter
* Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09 * Based on logo palette: #464646, #9a9696, #e5461f, #e3e3e3, #0d0b09
* Version: 3.0.0 * Version: 4.1.0
*/ */
/* ============================================ /* ============================================
+48
View File
@@ -522,6 +522,54 @@ body.dark .pdf-tab-container .pdf-controls {
flex-shrink: 0; flex-shrink: 0;
} }
.app-toast-container {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: var(--z-toast, 500);
pointer-events: none;
}
.app-toast {
min-width: 260px;
max-width: 420px;
padding: 12px 14px;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
color: #111827;
background: #ffffff;
border-left: 4px solid #3b82f6;
opacity: 0;
transform: translateY(-8px);
transition: opacity 0.18s ease, transform 0.18s ease;
pointer-events: auto;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.app-toast.visible {
opacity: 1;
transform: translateY(0);
}
.app-toast-info {
border-left-color: #2563eb;
}
.app-toast-success {
border-left-color: #059669;
background: #ecfdf5;
}
.app-toast-warning {
border-left-color: #d97706;
background: #fffbeb;
}
/* Dark theme adjustments for states */ /* Dark theme adjustments for states */
body[class*="dark"] .preview-error-message { body[class*="dark"] .preview-error-message {
color: #9ca3af; color: #9ca3af;
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Modal System Styles * Modal System Styles
* Unified modal components with glassmorphism backdrop * Unified modal components with glassmorphism backdrop
* @version 4.0.0 * @version 4.1.0
*/ */
/* ============================================ /* ============================================
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* ModalManager - Unified modal system with accessibility support * ModalManager - Unified modal system with accessibility support
* @version 4.0.0 * @version 4.1.0
*/ */
class ModalManager { class ModalManager {
#modal; #modal;
+2 -2
View File
@@ -10,7 +10,7 @@ function createWelcomeContent(recentFiles = []) {
<div class="welcome-container"> <div class="welcome-container">
<div class="welcome-hero"> <div class="welcome-hero">
<h1 class="welcome-title">MarkdownConverter</h1> <h1 class="welcome-title">MarkdownConverter</h1>
<p class="welcome-version">Version 4.0.0</p> <p class="welcome-version">Version 4.1.0</p>
<p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p> <p class="welcome-subtitle">Professional Markdown Editor & Universal Document Converter</p>
</div> </div>
@@ -45,7 +45,7 @@ function createWelcomeContent(recentFiles = []) {
</div> </div>
<div class="welcome-section"> <div class="welcome-section">
<h2>What's New in v4.0.0</h2> <h2>What's New in v4.x</h2>
<ul class="welcome-features"> <ul class="welcome-features">
<li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li> <li><strong>CodeMirror Editor</strong> — Syntax highlighting, code folding, multiple cursors</li>
<li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li> <li><strong>Sidebar Panels</strong> — File Explorer, Git, Snippets, Templates</li>
+8
View File
@@ -54,6 +54,14 @@ describe('Preload Security', () => {
'save-pasted-image', 'save-pasted-image',
'load-template', 'load-template',
'list-directory', 'list-directory',
'read-file',
'write-file',
'delete-file',
'ensure-directory',
'path-exists',
'is-directory',
'copy-path',
'move-path',
'open-file-path', 'open-file-path',
'git-status', 'git-status',
'git-stage', 'git-stage',
+9 -1
View File
@@ -17,7 +17,15 @@ global.window.electronAPI = {
setCurrent: jest.fn(), setCurrent: jest.fn(),
saveRecent: jest.fn(), saveRecent: jest.fn(),
clearRecent: jest.fn(), clearRecent: jest.fn(),
rendererReady: jest.fn() rendererReady: jest.fn(),
read: jest.fn(() => Promise.resolve('')),
write: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
ensureDir: jest.fn(() => Promise.resolve()),
exists: jest.fn(() => Promise.resolve(false)),
isDirectory: jest.fn(() => Promise.resolve(false)),
copy: jest.fn(() => Promise.resolve()),
move: jest.fn(() => Promise.resolve())
}, },
theme: { theme: {
get: jest.fn() get: jest.fn()