mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(export): add save/select/delete export presets
This commit is contained in:
+22
-16
@@ -352,22 +352,28 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Simple/Advanced Export Toggle -->
|
||||
<!-- Export Profiles -->
|
||||
<div class="export-section export-profiles">
|
||||
<label>Export Profile:</label>
|
||||
<div class="profile-controls">
|
||||
<select id="export-profile-select">
|
||||
<option value="">Custom Settings</option>
|
||||
</select>
|
||||
<button
|
||||
id="save-profile-btn"
|
||||
type="button"
|
||||
title="Save current settings as profile"
|
||||
>
|
||||
💾 Save
|
||||
</button>
|
||||
<button id="delete-profile-btn" type="button" title="Delete selected profile">
|
||||
🗑️ Delete
|
||||
<!-- Export Presets (main-process persisted; see src/renderer/export-presets.js) -->
|
||||
<div class="export-section export-presets">
|
||||
<label>Export Preset:</label>
|
||||
<div class="preset-controls">
|
||||
<div class="preset-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
id="preset-dropdown-toggle"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Custom Settings
|
||||
</button>
|
||||
<div
|
||||
id="preset-dropdown-list"
|
||||
class="preset-dropdown-list hidden"
|
||||
role="listbox"
|
||||
aria-label="Export presets"
|
||||
></div>
|
||||
</div>
|
||||
<button id="save-preset-btn" type="button" title="Save current settings as preset">
|
||||
💾 Save as preset
|
||||
</button>
|
||||
</div>
|
||||
<small class="export-help">Save and reuse your favorite export configurations</small>
|
||||
|
||||
+12
@@ -13,6 +13,7 @@ const GitOperations = require('./main/GitOperations');
|
||||
const PdfFontHeader = require('./main/PdfFontHeader');
|
||||
const MonospaceFontConfig = require('./main/MonospaceFontConfig');
|
||||
const ExportCss = require('./main/ExportCss');
|
||||
const ExportPresets = require('./main/ExportPresets');
|
||||
const EpubFontEmbedder = require('./main/EpubFontEmbedder');
|
||||
const DocxFontEmbedder = require('./main/DocxFontEmbedder');
|
||||
|
||||
@@ -2021,6 +2022,17 @@ ipcMain.on('save-header-footer-settings', (event, settings) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Export Presets IPC Handlers (invoke-style). Presets live in settings.json
|
||||
// under the `exportPresets` key — the same store used for header/footer and
|
||||
// page settings; list logic is in src/main/ExportPresets.js.
|
||||
ipcMain.handle('get-export-presets', async () => ExportPresets.loadPresets(store));
|
||||
ipcMain.handle('save-export-preset', async (event, preset) =>
|
||||
ExportPresets.savePreset(store, preset)
|
||||
);
|
||||
ipcMain.handle('delete-export-preset', async (event, presetId) =>
|
||||
ExportPresets.deletePreset(store, presetId)
|
||||
);
|
||||
|
||||
// Get current page settings
|
||||
ipcMain.on('get-page-settings', (event) => {
|
||||
event.reply('page-settings-data', pageSettings);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Export preset persistence (Task 21 — export presets/profiles).
|
||||
*
|
||||
* Pure list operations over the `exportPresets` array kept in the app's
|
||||
* settings.json store (the `store.get`/`store.set` helpers defined in
|
||||
* src/main.js — the same store that holds headerFooterSettings and
|
||||
* pageSettings). The store is injected so the logic is unit-testable with a
|
||||
* fake store (see tests/main/ExportPresets.test.js); src/main.js wires these
|
||||
* functions to the get-export-presets / save-export-preset /
|
||||
* delete-export-preset invoke channels.
|
||||
*
|
||||
* Preset shape: { id: string, name: string, format: string|null, options: object }
|
||||
* — `options` is the export-options snapshot captured from the renderer's
|
||||
* export dialog, so selecting a preset can pre-fill that dialog exactly.
|
||||
*/
|
||||
|
||||
const PRESET_KEY = 'exportPresets';
|
||||
const MAX_PRESETS = 50;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* Read the stored presets, defensively skipping corrupt data.
|
||||
* @param {{get: Function, set: Function}} store settings store
|
||||
* @returns {Array<{id: string, name: string, format: string|null, options: Object}>}
|
||||
*/
|
||||
function loadPresets(store) {
|
||||
const stored = store.get(PRESET_KEY, []);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
return stored.filter(
|
||||
(preset) =>
|
||||
preset &&
|
||||
typeof preset === 'object' &&
|
||||
typeof preset.id === 'string' &&
|
||||
typeof preset.name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate, normalize, and upsert a preset by id. A missing id gets a newly
|
||||
* generated one (insert); an id that already exists is replaced (update).
|
||||
* @param {{get: Function, set: Function}} store settings store
|
||||
* @param {{id?: string, name?: string, format?: string, options?: Object}} preset
|
||||
* @returns {Array} the updated preset list (also persisted)
|
||||
* @throws when the preset is not an object, the name is empty, or the cap is hit
|
||||
*/
|
||||
function savePreset(store, preset) {
|
||||
if (!preset || typeof preset !== 'object') {
|
||||
throw new Error('Preset must be an object');
|
||||
}
|
||||
const name = typeof preset.name === 'string' ? preset.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||
if (!name) {
|
||||
throw new Error('Preset name is required');
|
||||
}
|
||||
const options = preset.options && typeof preset.options === 'object' ? preset.options : {};
|
||||
const format = typeof preset.format === 'string' ? preset.format : null;
|
||||
|
||||
const presets = loadPresets(store);
|
||||
const id = typeof preset.id === 'string' && preset.id ? preset.id : createPresetId();
|
||||
const entry = { id, name, format, options };
|
||||
|
||||
const index = presets.findIndex((existing) => existing.id === id);
|
||||
if (index >= 0) {
|
||||
presets[index] = entry;
|
||||
} else {
|
||||
if (presets.length >= MAX_PRESETS) {
|
||||
throw new Error(`Cannot store more than ${MAX_PRESETS} export presets`);
|
||||
}
|
||||
presets.push(entry);
|
||||
}
|
||||
store.set(PRESET_KEY, presets);
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the preset with the given id (idempotent).
|
||||
* @param {{get: Function, set: Function}} store settings store
|
||||
* @param {string} presetId
|
||||
* @returns {Array} the updated preset list (also persisted)
|
||||
*/
|
||||
function deletePreset(store, presetId) {
|
||||
const presets = loadPresets(store).filter((preset) => preset.id !== presetId);
|
||||
store.set(PRESET_KEY, presets);
|
||||
return presets;
|
||||
}
|
||||
|
||||
function createPresetId() {
|
||||
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
module.exports = { loadPresets, savePreset, deletePreset, PRESET_KEY, MAX_PRESETS };
|
||||
@@ -74,6 +74,11 @@ const ALLOWED_SEND_CHANNELS = [
|
||||
'browse-word-template',
|
||||
'clear-word-template',
|
||||
|
||||
// Export presets (invoke channels — gated by this same array)
|
||||
'get-export-presets',
|
||||
'save-export-preset',
|
||||
'delete-export-preset',
|
||||
|
||||
// Page settings
|
||||
'get-page-settings',
|
||||
'update-page-settings',
|
||||
|
||||
+11
-126
@@ -13,6 +13,7 @@ const { createEditor } = require('./editor/codemirror-setup');
|
||||
const { undo, redo } = require('@codemirror/commands');
|
||||
const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog');
|
||||
const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog');
|
||||
const { initExportPresets, refreshExportPresets } = require('./renderer/export-presets');
|
||||
const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table');
|
||||
|
||||
/**
|
||||
@@ -2425,6 +2426,9 @@ function showExportDialog(format) {
|
||||
|
||||
// Initialize form values
|
||||
initializeExportForm(format);
|
||||
|
||||
// Populate the preset dropdown from the main-process store
|
||||
refreshExportPresets();
|
||||
}
|
||||
function hideExportDialog() {
|
||||
window.modals.exportModal.close();
|
||||
@@ -2599,129 +2603,14 @@ function collectExportOptions() {
|
||||
return options;
|
||||
}
|
||||
|
||||
// Export Profiles Management
|
||||
let exportProfiles = {};
|
||||
function loadExportProfiles() {
|
||||
const saved = localStorage.getItem('exportProfiles');
|
||||
if (saved) {
|
||||
try {
|
||||
exportProfiles = JSON.parse(saved);
|
||||
populateProfileDropdown();
|
||||
} catch (e) {
|
||||
console.error('Failed to load export profiles:', e);
|
||||
exportProfiles = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
function saveExportProfiles() {
|
||||
localStorage.setItem('exportProfiles', JSON.stringify(exportProfiles));
|
||||
}
|
||||
function populateProfileDropdown() {
|
||||
const select = document.getElementById('export-profile-select');
|
||||
if (!select) return;
|
||||
|
||||
// Clear existing options except the first one
|
||||
while (select.options.length > 1) {
|
||||
select.remove(1);
|
||||
}
|
||||
|
||||
// Add saved profiles
|
||||
Object.keys(exportProfiles).forEach((name) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
function saveCurrentProfile() {
|
||||
const name = prompt('Enter a name for this export profile:', 'My Profile');
|
||||
if (!name || name.trim() === '') return;
|
||||
const profileName = name.trim();
|
||||
|
||||
// Collect current settings
|
||||
const profile = {
|
||||
format: currentExportFormat,
|
||||
advancedMode: document.getElementById('advanced-export-toggle').checked,
|
||||
pageSize: document.getElementById('page-size').value,
|
||||
pageOrientation: document.getElementById('page-orientation').value,
|
||||
basicToc: document.getElementById('basic-toc').checked,
|
||||
basicNumberSections: document.getElementById('basic-number-sections').checked,
|
||||
};
|
||||
|
||||
// Add advanced options if enabled
|
||||
if (profile.advancedMode) {
|
||||
profile.template = document.getElementById('export-template').value;
|
||||
profile.toc = document.getElementById('export-toc').checked;
|
||||
profile.tocDepth = document.getElementById('export-toc-depth').value;
|
||||
profile.numberSections = document.getElementById('export-number-sections').checked;
|
||||
profile.citeproc = document.getElementById('export-citeproc').checked;
|
||||
if (currentExportFormat === 'pdf') {
|
||||
profile.pdfEngine = document.getElementById('pdf-engine').value;
|
||||
profile.pdfGeometry = document.getElementById('pdf-geometry').value;
|
||||
}
|
||||
}
|
||||
exportProfiles[profileName] = profile;
|
||||
saveExportProfiles();
|
||||
populateProfileDropdown();
|
||||
|
||||
// Select the newly created profile
|
||||
document.getElementById('export-profile-select').value = profileName;
|
||||
notifyUser(`Profile "${profileName}" saved successfully.`, 'success');
|
||||
}
|
||||
function loadProfile(profileName) {
|
||||
if (!profileName || !exportProfiles[profileName]) return;
|
||||
const profile = exportProfiles[profileName];
|
||||
|
||||
// Apply settings
|
||||
if (profile.advancedMode !== undefined) {
|
||||
document.getElementById('advanced-export-toggle').checked = profile.advancedMode;
|
||||
const advancedOptions = document.getElementById('advanced-export-options');
|
||||
if (profile.advancedMode) {
|
||||
advancedOptions.classList.remove('hidden');
|
||||
} else {
|
||||
advancedOptions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (profile.pageSize) document.getElementById('page-size').value = profile.pageSize;
|
||||
if (profile.pageOrientation)
|
||||
document.getElementById('page-orientation').value = profile.pageOrientation;
|
||||
if (profile.basicToc !== undefined)
|
||||
document.getElementById('basic-toc').checked = profile.basicToc;
|
||||
if (profile.basicNumberSections !== undefined)
|
||||
document.getElementById('basic-number-sections').checked = profile.basicNumberSections;
|
||||
|
||||
// Advanced options
|
||||
if (profile.advancedMode && profile.template)
|
||||
document.getElementById('export-template').value = profile.template;
|
||||
if (profile.toc !== undefined) document.getElementById('export-toc').checked = profile.toc;
|
||||
if (profile.tocDepth) document.getElementById('export-toc-depth').value = profile.tocDepth;
|
||||
if (profile.numberSections !== undefined)
|
||||
document.getElementById('export-number-sections').checked = profile.numberSections;
|
||||
if (profile.citeproc !== undefined)
|
||||
document.getElementById('export-citeproc').checked = profile.citeproc;
|
||||
if (profile.pdfEngine) document.getElementById('pdf-engine').value = profile.pdfEngine;
|
||||
if (profile.pdfGeometry) document.getElementById('pdf-geometry').value = profile.pdfGeometry;
|
||||
}
|
||||
function deleteSelectedProfile() {
|
||||
const select = document.getElementById('export-profile-select');
|
||||
const profileName = select.value;
|
||||
if (!profileName) {
|
||||
notifyUser('Select a profile to delete.', 'warning');
|
||||
return;
|
||||
}
|
||||
if (confirm(`Are you sure you want to delete the profile "${profileName}"?`)) {
|
||||
delete exportProfiles[profileName];
|
||||
saveExportProfiles();
|
||||
populateProfileDropdown();
|
||||
select.value = '';
|
||||
notifyUser(`Profile "${profileName}" deleted successfully.`, 'success');
|
||||
}
|
||||
}
|
||||
// Export Presets — the preset dropdown / "Save as preset" logic lives in
|
||||
// src/renderer/export-presets.js; presets are persisted by the main process
|
||||
// in settings.json (get-export-presets / save-export-preset /
|
||||
// delete-export-preset invoke channels), replacing the earlier
|
||||
// localStorage-only export profiles.
|
||||
|
||||
// Event listeners for export dialog
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load export profiles on startup
|
||||
loadExportProfiles();
|
||||
// Template selection
|
||||
document.getElementById('export-template').addEventListener('change', (e) => {
|
||||
const customPath = document.getElementById('custom-template-path');
|
||||
@@ -2812,12 +2701,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Export Profile buttons
|
||||
document.getElementById('save-profile-btn').addEventListener('click', saveCurrentProfile);
|
||||
document.getElementById('delete-profile-btn').addEventListener('click', deleteSelectedProfile);
|
||||
document.getElementById('export-profile-select').addEventListener('change', (e) => {
|
||||
loadProfile(e.target.value);
|
||||
});
|
||||
// Export Preset controls (dropdown, per-row delete, save-as-preset)
|
||||
initExportPresets({ notify: notifyUser });
|
||||
|
||||
// Add metadata field
|
||||
document.getElementById('add-metadata-field').addEventListener('click', () => {
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Export Presets UI
|
||||
*
|
||||
* Preset dropdown + "Save as preset" for the export-options dialog
|
||||
* (#export-dialog in src/index.html). Replaces the earlier localStorage-only
|
||||
* "export profiles": presets are now owned by the main process and persisted
|
||||
* in settings.json (`exportPresets` key) through three invoke channels —
|
||||
* get-export-presets / save-export-preset / delete-export-preset — so they
|
||||
* behave like every other app setting instead of living and dying with the
|
||||
* renderer's localStorage.
|
||||
*
|
||||
* Construction mirrors src/renderer/document-compare-dialog.js: a dialog
|
||||
* module in src/renderer/ using the raw `ipcRenderer` invoke surface
|
||||
* (nodeIntegration is enabled for this renderer). DOM is hand-rolled — a
|
||||
* button + row list instead of a native <select> so each preset row can carry
|
||||
* its own delete icon.
|
||||
*
|
||||
* The dialog markup lives in src/index.html; renderer.js calls
|
||||
* initExportPresets() once at startup and refreshExportPresets() whenever the
|
||||
* export dialog opens.
|
||||
*/
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
let currentPresets = [];
|
||||
let selectedPresetId = null;
|
||||
let notify = (message, type) => console.warn(`Export presets (${type}): ${message}`);
|
||||
|
||||
// ============================================
|
||||
// DOM helpers (all element access is guarded —
|
||||
// this module may outlive a dialog re-render)
|
||||
// ============================================
|
||||
|
||||
function elementById(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function valueOf(id) {
|
||||
const el = elementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function setValue(id, value) {
|
||||
const el = elementById(id);
|
||||
if (el) el.value = value;
|
||||
}
|
||||
|
||||
function isChecked(id) {
|
||||
const el = elementById(id);
|
||||
return !!(el && el.checked);
|
||||
}
|
||||
|
||||
function setChecked(id, checked) {
|
||||
const el = elementById(id);
|
||||
if (el) el.checked = checked;
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
const el = elementById(id);
|
||||
if (el) el.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function getDialogFormat() {
|
||||
const dialogEl = elementById('export-dialog');
|
||||
return dialogEl ? dialogEl.getAttribute('data-format') : null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Capture / restore of the dialog's option values
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Snapshot every option field of the export dialog into a plain object.
|
||||
* Side-effect free (unlike collectExportOptions in renderer.js, which also
|
||||
* pushes page settings to the main process) — the snapshot is stored as the
|
||||
* preset's `options` and replayed by applyPresetToDialog().
|
||||
* @returns {Object} options snapshot
|
||||
*/
|
||||
function captureDialogOptions() {
|
||||
const format = getDialogFormat();
|
||||
const advancedMode = isChecked('advanced-export-toggle');
|
||||
const options = {
|
||||
advancedMode,
|
||||
pageSize: valueOf('page-size'),
|
||||
pageOrientation: valueOf('page-orientation'),
|
||||
customWidth: valueOf('custom-width').trim() || null,
|
||||
customHeight: valueOf('custom-height').trim() || null,
|
||||
};
|
||||
|
||||
if (!advancedMode) {
|
||||
options.toc = isChecked('basic-toc');
|
||||
options.numberSections = isChecked('basic-number-sections');
|
||||
return options;
|
||||
}
|
||||
|
||||
const template = valueOf('export-template');
|
||||
options.template = template === 'custom' ? valueOf('custom-template-path').trim() : template;
|
||||
|
||||
options.metadata = {};
|
||||
document.querySelectorAll('.metadata-field').forEach((field) => {
|
||||
const key = field.querySelector('.metadata-key').value.trim();
|
||||
const value = field.querySelector('.metadata-value').value.trim();
|
||||
if (key && value) options.metadata[key] = value;
|
||||
});
|
||||
options.toc = isChecked('export-toc');
|
||||
options.tocDepth = valueOf('export-toc-depth') || '3';
|
||||
options.numberSections = isChecked('export-number-sections');
|
||||
options.citeproc = isChecked('export-citeproc');
|
||||
|
||||
if (format === 'pdf') {
|
||||
options.pdfEngine = valueOf('pdf-engine');
|
||||
const geometrySelect = valueOf('pdf-geometry');
|
||||
options.geometry =
|
||||
geometrySelect === 'custom'
|
||||
? valueOf('custom-geometry').trim() || 'margin=1in'
|
||||
: geometrySelect;
|
||||
}
|
||||
|
||||
if (format === 'revealjs') {
|
||||
options.revealTheme = valueOf('reveal-theme');
|
||||
options.revealTransition = valueOf('reveal-transition');
|
||||
options.revealTransitionSpeed = valueOf('reveal-speed');
|
||||
options.revealSlideNumber = isChecked('reveal-slide-number');
|
||||
options.revealControls = isChecked('reveal-controls');
|
||||
options.revealProgress = isChecked('reveal-progress');
|
||||
options.revealHistory = isChecked('reveal-history');
|
||||
options.revealCenter = isChecked('reveal-center');
|
||||
}
|
||||
|
||||
const bibliography = valueOf('bibliography-file').trim();
|
||||
const csl = valueOf('csl-file').trim();
|
||||
if (bibliography) options.bibliography = bibliography;
|
||||
if (csl) options.csl = csl;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a preset's option snapshot onto the dialog. Every field is written
|
||||
* explicitly (preset value or the dialog default), so switching from a rich
|
||||
* preset to a plain one clears whatever the rich one had set.
|
||||
* @param {{options?: Object}} preset preset to apply
|
||||
*/
|
||||
function applyPresetToDialog(preset) {
|
||||
const options = (preset && preset.options) || {};
|
||||
const advancedMode = options.advancedMode === true;
|
||||
|
||||
setChecked('advanced-export-toggle', advancedMode);
|
||||
const advancedSection = elementById('advanced-export-options');
|
||||
if (advancedSection) advancedSection.classList.toggle('hidden', !advancedMode);
|
||||
|
||||
// Page setup (applies to both modes)
|
||||
setValue('page-size', options.pageSize || 'a4');
|
||||
setValue('page-orientation', options.pageOrientation || 'portrait');
|
||||
setValue('custom-width', options.customWidth || '');
|
||||
setValue('custom-height', options.customHeight || '');
|
||||
setVisible('custom-page-size', valueOf('page-size') === 'custom');
|
||||
|
||||
setChecked('basic-toc', !advancedMode && options.toc === true);
|
||||
setChecked('basic-number-sections', !advancedMode && options.numberSections === true);
|
||||
|
||||
// In basic mode the advanced-only fields are reset to their defaults so no
|
||||
// stale values from a previously applied rich preset survive the switch.
|
||||
const advancedOptions = advancedMode ? options : {};
|
||||
|
||||
// Template: anything other than the literal default is a custom path
|
||||
const template =
|
||||
advancedOptions.template && advancedOptions.template !== 'default'
|
||||
? advancedOptions.template
|
||||
: null;
|
||||
if (template) {
|
||||
setValue('export-template', 'custom');
|
||||
setValue('custom-template-path', template);
|
||||
setVisible('custom-template-path', true);
|
||||
setVisible('template-file-input', true);
|
||||
} else {
|
||||
setValue('export-template', 'default');
|
||||
setValue('custom-template-path', '');
|
||||
setVisible('custom-template-path', false);
|
||||
setVisible('template-file-input', false);
|
||||
}
|
||||
|
||||
rebuildMetadataRows(advancedOptions.metadata || {});
|
||||
|
||||
setChecked('export-toc', advancedOptions.toc === true);
|
||||
setValue('export-toc-depth', advancedOptions.tocDepth || '3');
|
||||
setChecked('export-number-sections', advancedOptions.numberSections === true);
|
||||
setChecked('export-citeproc', advancedOptions.citeproc === true);
|
||||
|
||||
// PDF options
|
||||
setValue('pdf-engine', advancedOptions.pdfEngine || 'xelatex');
|
||||
const geometry = advancedOptions.geometry || 'margin=1in';
|
||||
const geometrySelect = elementById('pdf-geometry');
|
||||
const hasGeometryOption =
|
||||
geometrySelect && Array.from(geometrySelect.options).some((opt) => opt.value === geometry);
|
||||
if (hasGeometryOption) {
|
||||
setValue('pdf-geometry', geometry);
|
||||
setValue('custom-geometry', '');
|
||||
setVisible('custom-geometry', false);
|
||||
} else {
|
||||
setValue('pdf-geometry', 'custom');
|
||||
setValue('custom-geometry', geometry);
|
||||
setVisible('custom-geometry', true);
|
||||
}
|
||||
|
||||
// Reveal.js options
|
||||
setValue('reveal-theme', advancedOptions.revealTheme || 'black');
|
||||
setValue('reveal-transition', advancedOptions.revealTransition || 'slide');
|
||||
setValue('reveal-speed', advancedOptions.revealTransitionSpeed || 'default');
|
||||
setChecked('reveal-slide-number', advancedOptions.revealSlideNumber === true);
|
||||
setChecked('reveal-controls', advancedOptions.revealControls !== false);
|
||||
setChecked('reveal-progress', advancedOptions.revealProgress !== false);
|
||||
setChecked('reveal-history', advancedOptions.revealHistory !== false);
|
||||
setChecked('reveal-center', advancedOptions.revealCenter !== false);
|
||||
|
||||
setValue('bibliography-file', advancedOptions.bibliography || '');
|
||||
setValue('csl-file', advancedOptions.csl || '');
|
||||
}
|
||||
|
||||
function rebuildMetadataRows(metadata) {
|
||||
const container = document.querySelector('.metadata-container');
|
||||
if (!container) return;
|
||||
const entries = Object.keys(metadata).map((key) => [key, metadata[key]]);
|
||||
if (entries.length === 0) {
|
||||
['title', 'author', 'date', 'subject'].forEach((key) => entries.push([key, '']));
|
||||
}
|
||||
container.innerHTML = '';
|
||||
entries.forEach(([key, value]) => {
|
||||
const field = document.createElement('div');
|
||||
field.className = 'metadata-field';
|
||||
const keyInput = document.createElement('input');
|
||||
keyInput.type = 'text';
|
||||
keyInput.className = 'metadata-key';
|
||||
keyInput.value = key;
|
||||
const valueInput = document.createElement('input');
|
||||
valueInput.type = 'text';
|
||||
valueInput.className = 'metadata-value';
|
||||
valueInput.value = value;
|
||||
field.append(keyInput, valueInput);
|
||||
container.appendChild(field);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Preset dropdown rendering + interaction
|
||||
// ============================================
|
||||
|
||||
function createPresetRow(preset) {
|
||||
const selected = preset.id === selectedPresetId;
|
||||
const row = document.createElement('div');
|
||||
row.className = selected ? 'preset-row selected' : 'preset-row';
|
||||
row.dataset.id = preset.id;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
|
||||
const selectButton = document.createElement('button');
|
||||
selectButton.type = 'button';
|
||||
selectButton.className = 'preset-row-select';
|
||||
selectButton.textContent = preset.name;
|
||||
row.appendChild(selectButton);
|
||||
|
||||
if (preset.format) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'preset-format';
|
||||
badge.textContent = preset.format;
|
||||
row.appendChild(badge);
|
||||
}
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.type = 'button';
|
||||
deleteButton.className = 'preset-delete';
|
||||
deleteButton.textContent = '×';
|
||||
deleteButton.title = `Delete preset ${preset.name}`;
|
||||
deleteButton.setAttribute('aria-label', `Delete preset ${preset.name}`);
|
||||
row.appendChild(deleteButton);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderPresets() {
|
||||
const list = elementById('preset-dropdown-list');
|
||||
const toggle = elementById('preset-dropdown-toggle');
|
||||
if (!list || !toggle) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
if (currentPresets.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'preset-empty';
|
||||
empty.textContent = 'No saved presets';
|
||||
list.appendChild(empty);
|
||||
} else {
|
||||
currentPresets.forEach((preset) => list.appendChild(createPresetRow(preset)));
|
||||
}
|
||||
|
||||
const selected = currentPresets.find((preset) => preset.id === selectedPresetId);
|
||||
toggle.textContent = selected ? selected.name : 'Custom Settings';
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
const list = elementById('preset-dropdown-list');
|
||||
if (!list) return;
|
||||
const opened = !list.classList.contains('hidden');
|
||||
list.classList.toggle('hidden');
|
||||
const toggle = elementById('preset-dropdown-toggle');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', String(!opened));
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
const list = elementById('preset-dropdown-list');
|
||||
if (list) list.classList.add('hidden');
|
||||
const toggle = elementById('preset-dropdown-toggle');
|
||||
if (toggle) toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function handleListClick(event) {
|
||||
const deleteButton = event.target.closest('.preset-delete');
|
||||
const row = event.target.closest('.preset-row');
|
||||
if (deleteButton && row) {
|
||||
deleteExportPreset(row.dataset.id);
|
||||
return;
|
||||
}
|
||||
if (row) selectPreset(row.dataset.id);
|
||||
}
|
||||
|
||||
function selectPreset(presetId) {
|
||||
const preset = currentPresets.find((candidate) => candidate.id === presetId);
|
||||
if (!preset) return;
|
||||
selectedPresetId = presetId;
|
||||
applyPresetToDialog(preset);
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// IPC-backed preset operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Fetch presets from the main process and re-render the dropdown.
|
||||
* Called whenever the export dialog opens.
|
||||
*/
|
||||
async function refreshExportPresets() {
|
||||
try {
|
||||
const presets = await ipcRenderer.invoke('get-export-presets');
|
||||
currentPresets = Array.isArray(presets) ? presets : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load export presets:', error);
|
||||
currentPresets = [];
|
||||
}
|
||||
if (!currentPresets.some((preset) => preset.id === selectedPresetId)) {
|
||||
selectedPresetId = null;
|
||||
}
|
||||
renderPresets();
|
||||
}
|
||||
|
||||
async function saveCurrentAsPreset() {
|
||||
const selected = currentPresets.find((preset) => preset.id === selectedPresetId);
|
||||
const defaultName = selected ? selected.name : 'My Preset';
|
||||
const answer = window.prompt('Enter a name for this export preset:', defaultName);
|
||||
if (answer === null) return; // user cancelled the prompt
|
||||
const name = answer.trim();
|
||||
if (!name) {
|
||||
notify('Preset name cannot be empty.', 'warning');
|
||||
return;
|
||||
}
|
||||
// A selected preset is overwritten (same id); otherwise a new id is minted.
|
||||
// The main process re-validates and remains the source of truth.
|
||||
const preset = {
|
||||
id: selected ? selected.id : createPresetId(),
|
||||
name,
|
||||
format: getDialogFormat(),
|
||||
options: captureDialogOptions(),
|
||||
};
|
||||
try {
|
||||
const presets = await ipcRenderer.invoke('save-export-preset', preset);
|
||||
currentPresets = Array.isArray(presets) ? presets : currentPresets;
|
||||
// Normally the saved preset keeps the id we sent; fall back to the last
|
||||
// entry with the same name should the main process have normalized it.
|
||||
selectedPresetId = preset.id;
|
||||
if (!currentPresets.some((candidate) => candidate.id === selectedPresetId)) {
|
||||
const byName = currentPresets.filter((candidate) => candidate.name === name).pop();
|
||||
selectedPresetId = byName ? byName.id : null;
|
||||
}
|
||||
renderPresets();
|
||||
notify(`Preset "${name}" saved.`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to save export preset:', error);
|
||||
notify('Failed to save preset. Please try again.', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteExportPreset(presetId) {
|
||||
const preset = currentPresets.find((candidate) => candidate.id === presetId);
|
||||
const label = preset ? preset.name : 'this preset';
|
||||
if (!window.confirm(`Are you sure you want to delete the preset "${label}"?`)) return;
|
||||
try {
|
||||
const presets = await ipcRenderer.invoke('delete-export-preset', presetId);
|
||||
currentPresets = Array.isArray(presets) ? presets : currentPresets;
|
||||
if (selectedPresetId === presetId) selectedPresetId = null;
|
||||
renderPresets();
|
||||
notify(`Preset "${label}" deleted.`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete export preset:', error);
|
||||
notify('Failed to delete preset. Please try again.', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function createPresetId() {
|
||||
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the preset section of the export dialog. Call once after DOM ready.
|
||||
* @param {{notify?: Function}} options hooks from renderer.js
|
||||
*/
|
||||
function initExportPresets(options = {}) {
|
||||
if (typeof options.notify === 'function') notify = options.notify;
|
||||
selectedPresetId = null;
|
||||
const saveButton = elementById('save-preset-btn');
|
||||
if (saveButton) saveButton.addEventListener('click', saveCurrentAsPreset);
|
||||
const toggle = elementById('preset-dropdown-toggle');
|
||||
if (toggle) toggle.addEventListener('click', toggleDropdown);
|
||||
const list = elementById('preset-dropdown-list');
|
||||
if (list) list.addEventListener('click', handleListClick);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initExportPresets,
|
||||
refreshExportPresets,
|
||||
captureDialogOptions,
|
||||
applyPresetToDialog,
|
||||
};
|
||||
+98
-10
@@ -1491,29 +1491,26 @@ body.theme-github .line-numbers {
|
||||
color: #586069;
|
||||
}
|
||||
|
||||
/* Export Profiles Styles */
|
||||
.export-profiles {
|
||||
/* Export Presets Styles */
|
||||
.export-presets {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.profile-controls {
|
||||
.preset-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.profile-controls select {
|
||||
.preset-dropdown {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-controls button {
|
||||
.preset-dropdown button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
@@ -1523,7 +1520,98 @@ body.theme-github .line-numbers {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.profile-controls button:hover {
|
||||
.preset-dropdown button:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
#preset-dropdown-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.preset-dropdown-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.preset-dropdown-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.preset-row.selected {
|
||||
background: #eef4fb;
|
||||
}
|
||||
|
||||
.preset-row:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.preset-row-select {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
padding: 6px 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preset-format {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #586069;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.preset-delete {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #586069;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.preset-delete:hover {
|
||||
color: #cb2431;
|
||||
}
|
||||
|
||||
.preset-empty {
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
color: #586069;
|
||||
}
|
||||
|
||||
#save-preset-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
#save-preset-btn:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* Tests for the Export Presets dialog UI (Task 21).
|
||||
* Exercises the preset dropdown / save / delete flows of the export-options
|
||||
* dialog against a jsdom replica of the dialog's markup, with the electron
|
||||
* IPC surface mocked — following the jest.mock('electron') pattern in
|
||||
* document-compare-dialog.test.js.
|
||||
*/
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcRenderer: {
|
||||
invoke: jest.fn(),
|
||||
send: jest.fn(),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
const {
|
||||
initExportPresets,
|
||||
refreshExportPresets,
|
||||
captureDialogOptions,
|
||||
applyPresetToDialog,
|
||||
} = require('../src/renderer/export-presets');
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
function metadataRow(key = '', value = '') {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'metadata-field';
|
||||
const keyInput = document.createElement('input');
|
||||
keyInput.type = 'text';
|
||||
keyInput.className = 'metadata-key';
|
||||
keyInput.value = key;
|
||||
const valueInput = document.createElement('input');
|
||||
valueInput.type = 'text';
|
||||
valueInput.className = 'metadata-value';
|
||||
valueInput.value = value;
|
||||
row.append(keyInput, valueInput);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal replica of the #export-dialog markup in src/index.html — only the
|
||||
* elements the presets module reads or writes.
|
||||
*/
|
||||
function buildDialogFixture(format = 'pdf') {
|
||||
document.body.innerHTML = `
|
||||
<div id="export-dialog" data-format="${format}">
|
||||
<div class="export-section export-presets">
|
||||
<div class="preset-controls">
|
||||
<div class="preset-dropdown">
|
||||
<button type="button" id="preset-dropdown-toggle">Custom Settings</button>
|
||||
<div id="preset-dropdown-list" class="preset-dropdown-list hidden"></div>
|
||||
</div>
|
||||
<button id="save-preset-btn" type="button">Save as preset</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" id="advanced-export-toggle" />
|
||||
<div id="advanced-export-options" class="advanced-options hidden"></div>
|
||||
<div class="export-section pdf-only">
|
||||
<select id="pdf-engine">
|
||||
<option value="xelatex">XeLaTeX</option>
|
||||
<option value="pdflatex">PDFLaTeX</option>
|
||||
<option value="lualatex">LuaLaTeX</option>
|
||||
</select>
|
||||
<select id="pdf-geometry">
|
||||
<option value="margin=1in">1in</option>
|
||||
<option value="margin=2in">2in</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input type="text" id="custom-geometry" style="display: none" />
|
||||
</div>
|
||||
<div class="export-section revealjs-only">
|
||||
<select id="reveal-theme"><option value="black">black</option><option value="white">white</option></select>
|
||||
<select id="reveal-transition"><option value="slide">slide</option><option value="fade">fade</option></select>
|
||||
<select id="reveal-speed"><option value="default">default</option><option value="fast">fast</option></select>
|
||||
<input type="checkbox" id="reveal-slide-number" />
|
||||
<input type="checkbox" id="reveal-controls" />
|
||||
<input type="checkbox" id="reveal-progress" />
|
||||
<input type="checkbox" id="reveal-history" />
|
||||
<input type="checkbox" id="reveal-center" />
|
||||
</div>
|
||||
<div class="export-section">
|
||||
<select id="export-template">
|
||||
<option value="default">Default</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input type="file" id="template-file-input" style="display: none" />
|
||||
<input type="text" id="custom-template-path" style="display: none" />
|
||||
<div class="metadata-container"></div>
|
||||
<input type="checkbox" id="export-toc" />
|
||||
<input type="number" id="export-toc-depth" value="3" min="1" max="6" />
|
||||
<input type="checkbox" id="export-number-sections" />
|
||||
<input type="checkbox" id="export-citeproc" />
|
||||
<input type="text" id="bibliography-file" />
|
||||
<input type="text" id="csl-file" />
|
||||
</div>
|
||||
<div class="export-section">
|
||||
<input type="checkbox" id="basic-toc" />
|
||||
<input type="checkbox" id="basic-number-sections" />
|
||||
<select id="page-size">
|
||||
<option value="a4">A4</option>
|
||||
<option value="letter">Letter</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<select id="page-orientation">
|
||||
<option value="portrait">Portrait</option>
|
||||
<option value="landscape">Landscape</option>
|
||||
</select>
|
||||
<div id="custom-page-size" style="display: none">
|
||||
<input type="text" id="custom-width" />
|
||||
<input type="text" id="custom-height" />
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
// Seed the four default metadata rows the dialog ships with.
|
||||
const container = document.querySelector('.metadata-container');
|
||||
['title', 'author', 'date', 'subject'].forEach((key) => {
|
||||
container.appendChild(metadataRow(key, ''));
|
||||
});
|
||||
}
|
||||
|
||||
const advancedPdfPreset = {
|
||||
id: 'preset-pdf1',
|
||||
name: 'Book PDF',
|
||||
format: 'pdf',
|
||||
options: {
|
||||
advancedMode: true,
|
||||
template: '/home/user/templates/book.tex',
|
||||
metadata: { title: 'My Book', author: 'Jane' },
|
||||
toc: true,
|
||||
tocDepth: 4,
|
||||
numberSections: true,
|
||||
citeproc: false,
|
||||
pdfEngine: 'lualatex',
|
||||
geometry: 'margin=2.5cm',
|
||||
bibliography: '/refs.bib',
|
||||
pageSize: 'custom',
|
||||
pageOrientation: 'landscape',
|
||||
customWidth: '210mm',
|
||||
customHeight: '297mm',
|
||||
},
|
||||
};
|
||||
|
||||
const basicPreset = {
|
||||
id: 'preset-basic1',
|
||||
name: 'Quick HTML',
|
||||
format: 'html',
|
||||
options: {
|
||||
advancedMode: false,
|
||||
toc: true,
|
||||
numberSections: false,
|
||||
pageSize: 'letter',
|
||||
pageOrientation: 'portrait',
|
||||
},
|
||||
};
|
||||
|
||||
function mockInvoke(presets) {
|
||||
ipcRenderer.invoke.mockImplementation((channel) => {
|
||||
if (channel === 'get-export-presets') return Promise.resolve(presets);
|
||||
return Promise.resolve(presets);
|
||||
});
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return Array.from(document.querySelectorAll('#preset-dropdown-list .preset-row'));
|
||||
}
|
||||
|
||||
describe('Export presets dialog', () => {
|
||||
let notify;
|
||||
|
||||
beforeEach(() => {
|
||||
buildDialogFixture('pdf');
|
||||
ipcRenderer.invoke.mockReset();
|
||||
notify = jest.fn();
|
||||
initExportPresets({ notify });
|
||||
});
|
||||
|
||||
describe('refreshExportPresets', () => {
|
||||
it('loads presets via get-export-presets and renders one row per preset with a delete icon', async () => {
|
||||
mockInvoke([advancedPdfPreset, basicPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledWith('get-export-presets');
|
||||
expect(rows()).toHaveLength(2);
|
||||
expect(rows()[0].textContent).toContain('Book PDF');
|
||||
expect(rows()[0].querySelector('.preset-delete')).not.toBeNull();
|
||||
expect(rows()[1].textContent).toContain('Quick HTML');
|
||||
});
|
||||
|
||||
it('shows the format badge next to each preset name', async () => {
|
||||
mockInvoke([advancedPdfPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
expect(rows()[0].querySelector('.preset-format').textContent).toBe('pdf');
|
||||
});
|
||||
|
||||
it('renders an empty notice when there are no presets', async () => {
|
||||
mockInvoke([]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
expect(rows()).toHaveLength(0);
|
||||
expect(document.getElementById('preset-dropdown-list').textContent).toContain(
|
||||
'No saved presets'
|
||||
);
|
||||
});
|
||||
|
||||
it('survives a rejected get-export-presets call without crashing', async () => {
|
||||
ipcRenderer.invoke.mockRejectedValue(new Error('boom'));
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
expect(rows()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selecting a preset', () => {
|
||||
it('pre-fills every dialog field from an advanced PDF preset', async () => {
|
||||
mockInvoke([advancedPdfPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
rows()[0].querySelector('.preset-row-select').click();
|
||||
|
||||
// Advanced mode
|
||||
expect(document.getElementById('advanced-export-toggle').checked).toBe(true);
|
||||
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
|
||||
false
|
||||
);
|
||||
// Template: custom path
|
||||
expect(document.getElementById('export-template').value).toBe('custom');
|
||||
expect(document.getElementById('custom-template-path').value).toBe(
|
||||
'/home/user/templates/book.tex'
|
||||
);
|
||||
expect(document.getElementById('custom-template-path').style.display).toBe('block');
|
||||
// Metadata rows rebuilt from the preset
|
||||
const keys = Array.from(document.querySelectorAll('.metadata-key')).map((i) => i.value);
|
||||
const values = Array.from(document.querySelectorAll('.metadata-value')).map((i) => i.value);
|
||||
expect(keys).toEqual(['title', 'author']);
|
||||
expect(values).toEqual(['My Book', 'Jane']);
|
||||
// Document options
|
||||
expect(document.getElementById('export-toc').checked).toBe(true);
|
||||
expect(document.getElementById('export-toc-depth').value).toBe('4');
|
||||
expect(document.getElementById('export-number-sections').checked).toBe(true);
|
||||
expect(document.getElementById('export-citeproc').checked).toBe(false);
|
||||
// PDF options with a non-preset geometry -> custom
|
||||
expect(document.getElementById('pdf-engine').value).toBe('lualatex');
|
||||
expect(document.getElementById('pdf-geometry').value).toBe('custom');
|
||||
expect(document.getElementById('custom-geometry').value).toBe('margin=2.5cm');
|
||||
expect(document.getElementById('custom-geometry').style.display).toBe('block');
|
||||
// Bibliography
|
||||
expect(document.getElementById('bibliography-file').value).toBe('/refs.bib');
|
||||
// Page setup
|
||||
expect(document.getElementById('page-size').value).toBe('custom');
|
||||
expect(document.getElementById('custom-page-size').style.display).toBe('block');
|
||||
expect(document.getElementById('custom-width').value).toBe('210mm');
|
||||
expect(document.getElementById('custom-height').value).toBe('297mm');
|
||||
expect(document.getElementById('page-orientation').value).toBe('landscape');
|
||||
});
|
||||
|
||||
it('pre-fills basic-mode checkboxes and leaves advanced options hidden', async () => {
|
||||
mockInvoke([basicPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
rows()[0].querySelector('.preset-row-select').click();
|
||||
|
||||
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
|
||||
expect(document.getElementById('advanced-export-options').classList.contains('hidden')).toBe(
|
||||
true
|
||||
);
|
||||
expect(document.getElementById('basic-toc').checked).toBe(true);
|
||||
expect(document.getElementById('basic-number-sections').checked).toBe(false);
|
||||
expect(document.getElementById('page-size').value).toBe('letter');
|
||||
});
|
||||
|
||||
it('marks the selected row, updates the toggle label and closes the dropdown', async () => {
|
||||
mockInvoke([advancedPdfPreset, basicPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
document.getElementById('preset-dropdown-toggle').click();
|
||||
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
rows()[1].querySelector('.preset-row-select').click();
|
||||
|
||||
expect(rows()[1].classList.contains('selected')).toBe(true);
|
||||
expect(rows()[0].classList.contains('selected')).toBe(false);
|
||||
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Quick HTML');
|
||||
expect(document.getElementById('preset-dropdown-list').classList.contains('hidden')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('resets stale field values when switching from a rich preset to a plain one', async () => {
|
||||
mockInvoke([advancedPdfPreset, basicPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
|
||||
rows()[0].querySelector('.preset-row-select').click();
|
||||
rows()[1].querySelector('.preset-row-select').click();
|
||||
|
||||
// The basic preset has no bibliography — the field must be cleared, not left over.
|
||||
expect(document.getElementById('bibliography-file').value).toBe('');
|
||||
expect(document.getElementById('advanced-export-toggle').checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving the current dialog state as a preset', () => {
|
||||
it('prompts for a name and invokes save-export-preset with the captured options', async () => {
|
||||
mockInvoke([]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
|
||||
|
||||
// Configure the dialog the way a user would before saving.
|
||||
document.getElementById('advanced-export-toggle').checked = true;
|
||||
document.getElementById('export-toc').checked = true;
|
||||
document.getElementById('pdf-engine').value = 'pdflatex';
|
||||
document.getElementById('page-size').value = 'letter';
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledWith(
|
||||
'save-export-preset',
|
||||
expect.objectContaining({
|
||||
name: 'My Preset',
|
||||
format: 'pdf',
|
||||
})
|
||||
);
|
||||
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
|
||||
expect(saved.id).toMatch(/^preset-/);
|
||||
expect(saved.options.advancedMode).toBe(true);
|
||||
expect(saved.options.toc).toBe(true);
|
||||
expect(saved.options.pdfEngine).toBe('pdflatex');
|
||||
expect(saved.options.pageSize).toBe('letter');
|
||||
expect(notify).toHaveBeenCalledWith('Preset "My Preset" saved.', 'success');
|
||||
});
|
||||
|
||||
it('re-renders the dropdown from the list returned by save-export-preset', async () => {
|
||||
// Mirror the main process: the saved preset echoes back with the id that was sent.
|
||||
ipcRenderer.invoke.mockImplementation((channel, payload) => {
|
||||
if (channel === 'get-export-presets') return Promise.resolve([]);
|
||||
return Promise.resolve([
|
||||
{ id: payload.id, name: payload.name, format: payload.format, options: payload.options },
|
||||
]);
|
||||
});
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('My Preset');
|
||||
});
|
||||
|
||||
it('reuses the selected preset id so saving overwrites instead of duplicating', async () => {
|
||||
mockInvoke([advancedPdfPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
rows()[0].querySelector('.preset-row-select').click();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue('Book PDF v2');
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
const saved = ipcRenderer.invoke.mock.calls.find((c) => c[0] === 'save-export-preset')[1];
|
||||
expect(saved.id).toBe('preset-pdf1');
|
||||
expect(saved.name).toBe('Book PDF v2');
|
||||
});
|
||||
|
||||
it('does nothing when the user cancels the name prompt', async () => {
|
||||
mockInvoke([]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue(null);
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and skips the save when the name is empty', async () => {
|
||||
mockInvoke([]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue(' ');
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(notify).toHaveBeenCalledWith('Preset name cannot be empty.', 'warning');
|
||||
});
|
||||
|
||||
it('warns without throwing when the save IPC call rejects', async () => {
|
||||
ipcRenderer.invoke.mockImplementation((channel) =>
|
||||
channel === 'get-export-presets' ? Promise.resolve([]) : Promise.reject(new Error('disk'))
|
||||
);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'prompt').mockReturnValue('My Preset');
|
||||
|
||||
document.getElementById('save-preset-btn').click();
|
||||
await flush();
|
||||
|
||||
expect(notify).toHaveBeenCalledWith('Failed to save preset. Please try again.', 'warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting a preset', () => {
|
||||
it('asks for confirmation and invokes delete-export-preset with the row id', async () => {
|
||||
mockInvoke([advancedPdfPreset, basicPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
ipcRenderer.invoke.mockImplementation((channel) =>
|
||||
channel === 'delete-export-preset'
|
||||
? Promise.resolve([basicPreset])
|
||||
: Promise.resolve([advancedPdfPreset, basicPreset])
|
||||
);
|
||||
|
||||
rows()[0].querySelector('.preset-delete').click();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledWith('delete-export-preset', 'preset-pdf1');
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0].textContent).toContain('Quick HTML');
|
||||
});
|
||||
|
||||
it('clears the selection when the deleted preset was selected', async () => {
|
||||
mockInvoke([advancedPdfPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
rows()[0].querySelector('.preset-row-select').click();
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
ipcRenderer.invoke.mockImplementation((channel) =>
|
||||
channel === 'delete-export-preset'
|
||||
? Promise.resolve([])
|
||||
: Promise.resolve([advancedPdfPreset])
|
||||
);
|
||||
|
||||
rows()[0].querySelector('.preset-delete').click();
|
||||
await flush();
|
||||
|
||||
expect(document.getElementById('preset-dropdown-toggle').textContent).toBe('Custom Settings');
|
||||
});
|
||||
|
||||
it('does not invoke delete when the user cancels the confirmation', async () => {
|
||||
mockInvoke([advancedPdfPreset]);
|
||||
await refreshExportPresets();
|
||||
await flush();
|
||||
jest.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
rows()[0].querySelector('.preset-delete').click();
|
||||
await flush();
|
||||
|
||||
expect(ipcRenderer.invoke).toHaveBeenCalledTimes(1); // only the initial get
|
||||
expect(rows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureDialogOptions / applyPresetToDialog round-trip', () => {
|
||||
it('restores exactly what was captured, including reveal.js fields', () => {
|
||||
buildDialogFixture('revealjs');
|
||||
initExportPresets({ notify });
|
||||
|
||||
document.getElementById('advanced-export-toggle').checked = true;
|
||||
document.getElementById('reveal-theme').value = 'white';
|
||||
document.getElementById('reveal-transition').value = 'fade';
|
||||
document.getElementById('reveal-speed').value = 'fast';
|
||||
document.getElementById('reveal-slide-number').checked = true;
|
||||
document.getElementById('reveal-controls').checked = false;
|
||||
document.getElementById('reveal-progress').checked = false;
|
||||
document.getElementById('reveal-history').checked = false;
|
||||
document.getElementById('reveal-center').checked = false;
|
||||
document.getElementById('csl-file').value = '/styles.csl';
|
||||
|
||||
const captured = captureDialogOptions();
|
||||
|
||||
buildDialogFixture('revealjs');
|
||||
initExportPresets({ notify });
|
||||
applyPresetToDialog({ options: captured });
|
||||
|
||||
expect(document.getElementById('reveal-theme').value).toBe('white');
|
||||
expect(document.getElementById('reveal-transition').value).toBe('fade');
|
||||
expect(document.getElementById('reveal-speed').value).toBe('fast');
|
||||
expect(document.getElementById('reveal-slide-number').checked).toBe(true);
|
||||
expect(document.getElementById('reveal-controls').checked).toBe(false);
|
||||
expect(document.getElementById('reveal-progress').checked).toBe(false);
|
||||
expect(document.getElementById('reveal-history').checked).toBe(false);
|
||||
expect(document.getElementById('reveal-center').checked).toBe(false);
|
||||
expect(document.getElementById('csl-file').value).toBe('/styles.csl');
|
||||
});
|
||||
|
||||
it('captures a preset geometry as-is and restores it back onto the select', () => {
|
||||
document.getElementById('advanced-export-toggle').checked = true;
|
||||
document.getElementById('pdf-geometry').value = 'margin=2in';
|
||||
|
||||
const captured = captureDialogOptions();
|
||||
expect(captured.geometry).toBe('margin=2in');
|
||||
|
||||
buildDialogFixture('pdf');
|
||||
initExportPresets({ notify });
|
||||
applyPresetToDialog({ options: captured });
|
||||
expect(document.getElementById('pdf-geometry').value).toBe('margin=2in');
|
||||
expect(document.getElementById('custom-geometry').style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('captures basic mode without any advanced keys leaking in', () => {
|
||||
const captured = captureDialogOptions();
|
||||
expect(captured.advancedMode).toBe(false);
|
||||
expect(captured.toc).toBe(false);
|
||||
expect(captured.template).toBeUndefined();
|
||||
expect(captured.pdfEngine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Tests for the export-preset persistence module (Task 21).
|
||||
* The module is pure list logic over the settings store's `exportPresets`
|
||||
* array; the store is injected, mirroring the SettingsStore test pattern.
|
||||
*/
|
||||
|
||||
const ExportPresets = require('../../src/main/ExportPresets');
|
||||
|
||||
function createStore(initial = {}) {
|
||||
const data = { ...initial };
|
||||
return {
|
||||
get: (key, defaultValue) => (data[key] === undefined ? defaultValue : data[key]),
|
||||
set: (key, value) => {
|
||||
data[key] = value;
|
||||
},
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ExportPresets', () => {
|
||||
describe('loadPresets', () => {
|
||||
test('returns an empty array when nothing is stored', () => {
|
||||
expect(ExportPresets.loadPresets(createStore())).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns the stored presets', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [{ id: 'p1', name: 'Book PDF', format: 'pdf', options: { toc: true } }],
|
||||
});
|
||||
const presets = ExportPresets.loadPresets(store);
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(presets[0].name).toBe('Book PDF');
|
||||
});
|
||||
|
||||
test('returns an empty array when the stored value is corrupt (not an array)', () => {
|
||||
expect(ExportPresets.loadPresets(createStore({ exportPresets: 'garbage' }))).toEqual([]);
|
||||
expect(ExportPresets.loadPresets(createStore({ exportPresets: { p1: 1 } }))).toEqual([]);
|
||||
});
|
||||
|
||||
test('drops malformed entries (missing id or name) from the stored array', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [
|
||||
{ id: 'p1', name: 'Good', format: 'pdf', options: {} },
|
||||
{ name: 'No id', options: {} },
|
||||
{ id: 'p3', options: {} },
|
||||
null,
|
||||
],
|
||||
});
|
||||
const presets = ExportPresets.loadPresets(store);
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(presets[0].id).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePreset', () => {
|
||||
test('adds a new preset with a generated id and persists it', () => {
|
||||
const store = createStore();
|
||||
const presets = ExportPresets.savePreset(store, {
|
||||
name: 'Book PDF',
|
||||
format: 'pdf',
|
||||
options: { toc: true, tocDepth: 3 },
|
||||
});
|
||||
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(presets[0].id).toMatch(/^preset-/);
|
||||
expect(presets[0].name).toBe('Book PDF');
|
||||
expect(presets[0].format).toBe('pdf');
|
||||
expect(presets[0].options).toEqual({ toc: true, tocDepth: 3 });
|
||||
expect(store.data.exportPresets).toEqual(presets);
|
||||
});
|
||||
|
||||
test('upserts by id — saving with an existing id replaces the entry', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [{ id: 'p1', name: 'Old name', format: 'pdf', options: { toc: false } }],
|
||||
});
|
||||
const presets = ExportPresets.savePreset(store, {
|
||||
id: 'p1',
|
||||
name: 'New name',
|
||||
format: 'docx',
|
||||
options: { toc: true },
|
||||
});
|
||||
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(presets[0]).toEqual({
|
||||
id: 'p1',
|
||||
name: 'New name',
|
||||
format: 'docx',
|
||||
options: { toc: true },
|
||||
});
|
||||
expect(store.data.exportPresets).toEqual(presets);
|
||||
});
|
||||
|
||||
test('appends when the id is new, preserving existing presets', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [{ id: 'p1', name: 'First', format: 'pdf', options: {} }],
|
||||
});
|
||||
const presets = ExportPresets.savePreset(store, {
|
||||
name: 'Second',
|
||||
format: 'html',
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(presets).toHaveLength(2);
|
||||
expect(presets.map((p) => p.name)).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
test('throws when the preset is not an object', () => {
|
||||
const store = createStore();
|
||||
expect(() => ExportPresets.savePreset(store, null)).toThrow('Preset must be an object');
|
||||
expect(() => ExportPresets.savePreset(store, 'nope')).toThrow('Preset must be an object');
|
||||
});
|
||||
|
||||
test('throws when the name is missing or empty after trimming', () => {
|
||||
const store = createStore();
|
||||
expect(() => ExportPresets.savePreset(store, { name: ' ', options: {} })).toThrow(
|
||||
'Preset name is required'
|
||||
);
|
||||
expect(() => ExportPresets.savePreset(store, { options: {} })).toThrow(
|
||||
'Preset name is required'
|
||||
);
|
||||
});
|
||||
|
||||
test('trims the name and caps it at 100 characters', () => {
|
||||
const store = createStore();
|
||||
const presets = ExportPresets.savePreset(store, { name: ' Spaced ', options: {} });
|
||||
expect(presets[0].name).toBe('Spaced');
|
||||
|
||||
const long = ExportPresets.savePreset(store, { name: 'x'.repeat(150), options: {} });
|
||||
expect(long[1].name).toHaveLength(100);
|
||||
});
|
||||
|
||||
test('defaults a missing options object to {} and a missing format to null', () => {
|
||||
const store = createStore();
|
||||
const presets = ExportPresets.savePreset(store, { name: 'Bare' });
|
||||
expect(presets[0].options).toEqual({});
|
||||
expect(presets[0].format).toBeNull();
|
||||
});
|
||||
|
||||
test('refuses to add beyond the preset cap', () => {
|
||||
const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
name: `Preset ${i}`,
|
||||
format: 'pdf',
|
||||
options: {},
|
||||
}));
|
||||
const store = createStore({ exportPresets: full });
|
||||
expect(() => ExportPresets.savePreset(store, { name: 'One too many' })).toThrow(
|
||||
/more than \d+ export presets/
|
||||
);
|
||||
});
|
||||
|
||||
test('still allows updating an existing preset when the list is full', () => {
|
||||
const full = Array.from({ length: ExportPresets.MAX_PRESETS }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
name: `Preset ${i}`,
|
||||
format: 'pdf',
|
||||
options: {},
|
||||
}));
|
||||
const store = createStore({ exportPresets: full });
|
||||
const presets = ExportPresets.savePreset(store, { id: 'p7', name: 'Updated' });
|
||||
expect(presets).toHaveLength(ExportPresets.MAX_PRESETS);
|
||||
expect(presets.find((p) => p.id === 'p7').name).toBe('Updated');
|
||||
});
|
||||
|
||||
test('generates distinct ids for successive new presets', () => {
|
||||
const store = createStore();
|
||||
const a = ExportPresets.savePreset(store, { name: 'A' });
|
||||
const b = ExportPresets.savePreset(store, { name: 'B' });
|
||||
expect(a[0].id).not.toBe(b[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePreset', () => {
|
||||
test('removes the preset with the given id and returns the updated list', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [
|
||||
{ id: 'p1', name: 'Keep', format: 'pdf', options: {} },
|
||||
{ id: 'p2', name: 'Drop', format: 'html', options: {} },
|
||||
],
|
||||
});
|
||||
const presets = ExportPresets.deletePreset(store, 'p2');
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(presets[0].id).toBe('p1');
|
||||
expect(store.data.exportPresets).toEqual(presets);
|
||||
});
|
||||
|
||||
test('is idempotent when the id does not exist', () => {
|
||||
const store = createStore({
|
||||
exportPresets: [{ id: 'p1', name: 'Keep', format: 'pdf', options: {} }],
|
||||
});
|
||||
const presets = ExportPresets.deletePreset(store, 'missing');
|
||||
expect(presets).toHaveLength(1);
|
||||
expect(store.data.exportPresets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,9 @@ describe('Preload Security', () => {
|
||||
'browse-header-footer-logo',
|
||||
'save-header-footer-logo',
|
||||
'clear-header-footer-logo',
|
||||
'get-export-presets',
|
||||
'save-export-preset',
|
||||
'delete-export-preset',
|
||||
'get-page-settings',
|
||||
'update-page-settings',
|
||||
'process-pdf-operation',
|
||||
|
||||
Reference in New Issue
Block a user