mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-24 07:20:16 +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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user