diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index b2fcf13..de99f9d 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -413,28 +413,43 @@ function showExportDialog({ outDir: outDirInput.value.trim() || null, }); + const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'); + const previewBtn = el('button', { + onClick: async () => { + if (typeof onPreview !== 'function') return; + setButtonLoading(previewBtn, true, 'Preview…'); + try { + await onPreview(payload()); // keep dialog open so settings can be tweaked + } finally { + setButtonLoading(previewBtn, false); + } + }, + }, 'Preview'); + const exportBtn = el('button.primary', { + onClick: async () => { + if (typeof onExport !== 'function') return; + cancelBtn.disabled = true; + previewBtn.disabled = true; + setButtonLoading(exportBtn, true, 'Exporting…'); + try { + const ok = await onExport(payload()); + if (ok !== false) { + close(); + resolve(true); + return; + } + } finally { + cancelBtn.disabled = false; + previewBtn.disabled = false; + setButtonLoading(exportBtn, false); + } + }, + }, 'Export'); + const { close } = openModal({ title: 'Export', body, - footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), - el('button', { - onClick: async () => { - if (typeof onPreview !== 'function') return; - await onPreview(payload()); // keep dialog open so settings can be tweaked - }, - }, 'Preview'), - el('button.primary', { - onClick: async () => { - if (typeof onExport !== 'function') return; - const ok = await onExport(payload()); - if (ok !== false) { - close(); - resolve(true); - } - }, - }, 'Export'), - ], + footer: [cancelBtn, previewBtn, exportBtn], wide: true, onClose: () => resolve(false), }); diff --git a/app/renderer/style.css b/app/renderer/style.css index 06f6675..f0bda60 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -90,6 +90,25 @@ button.tool.active { border-color: var(--accent); color: var(--accent-fg); } +button:disabled { opacity: 0.6; cursor: default; } +button.loading { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.spinner { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + opacity: 0.85; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } input, select, textarea { font: inherit; diff --git a/app/renderer/util.js b/app/renderer/util.js index 47915cb..dc70189 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -34,6 +34,27 @@ function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } +/** + * Toggle a button into/out of a busy state: disables it and swaps its label + * for a spinner + `label` (e.g. "Exporting…"), restoring the original label + * afterwards. Used for actions that block on a slow main-process call. + */ +function setButtonLoading(btn, loading, label) { + if (loading) { + if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent; + btn.disabled = true; + btn.classList.add('loading'); + clearNode(btn); + btn.append(el('span.spinner'), label || btn.dataset.origLabel); + } else { + btn.disabled = false; + btn.classList.remove('loading'); + clearNode(btn); + btn.append(btn.dataset.origLabel ?? ''); + delete btn.dataset.origLabel; + } +} + function debounce(fn, ms) { let t = null; const wrapped = (...args) => {