fix(batch): resolve pandoc path handling and include-subfolders option

- Normalize pandoc command parsing with path.basename() to support bundled binary paths
- Use bundled pandoc binary in convertWithPandoc instead of relying on PATH
- Forward includeSubfolders checkbox state from renderer to main process
- Add pandoc availability check before batch conversion
- Re-enable Start button when batch conversion completes
- Clean up obsolete dist build artifact causing test snapshot warning
- Bump version to 4.4.4
This commit is contained in:
2026-06-30 12:56:33 +05:30
parent 02e307f758
commit d705cfc30b
86 changed files with 16876 additions and 13476 deletions
+204 -203
View File
@@ -1,245 +1,246 @@
/**
* ModalManager - Unified modal system with accessibility support
* @version 4.4.3
* @version 4.4.4
*/
class ModalManager {
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
#modal;
#backdrop;
#options;
#lastFocusedElement;
#focusableElements;
#eventListeners;
#isOpen;
static #openModals = [];
static #openModals = [];
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
constructor(element, options = {}) {
this.#modal = typeof element === 'string' ? document.querySelector(element) : element;
this.#options = {
closeOnBackdrop: true,
closeOnEscape: true,
focusFirst: true,
onOpen: null,
onClose: null,
...options,
};
this.#isOpen = false;
this.#eventListeners = [];
this.#init();
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
#init() {
// Ensure modal has required attributes
if (!this.#modal.hasAttribute('role')) {
this.#modal.setAttribute('role', 'dialog');
}
if (!this.#modal.hasAttribute('aria-modal')) {
this.#modal.setAttribute('aria-modal', 'true');
}
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Find or create backdrop
this.#backdrop = this.#modal.querySelector('.modal-backdrop');
// Setup close triggers
this.#setupCloseTriggers();
}
// Setup close triggers
this.#setupCloseTriggers();
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
#setupCloseTriggers() {
// Close button
const closeBtn = this.#modal.querySelector('.modal-close');
if (closeBtn) {
const handler = (e) => {
e.preventDefault();
this.close();
};
closeBtn.addEventListener('click', handler);
this.#eventListeners.push({ el: closeBtn, type: 'click', handler });
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach((el) => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
}
// Elements with data-close attribute
const closeTriggers = this.#modal.querySelectorAll('[data-close]');
closeTriggers.forEach(el => {
if (el.classList.contains('modal-backdrop') && !this.#options.closeOnBackdrop) {
return;
}
const handler = (e) => {
e.preventDefault();
this.close();
};
el.addEventListener('click', handler);
this.#eventListeners.push({ el, type: 'click', handler });
});
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector)).filter(
(el) => el.offsetParent !== null && !el.classList.contains('modal-backdrop')
);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
}
#getFocusableElements() {
const selector = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
return Array.from(this.#modal.querySelectorAll(selector))
.filter(el => el.offsetParent !== null && !el.classList.contains('modal-backdrop'));
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
}
#trapFocus(e) {
if (e.key !== 'Tab') return;
open() {
if (this.#isOpen) return;
// Store last focused element
this.#lastFocusedElement = document.activeElement;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length === 0) return;
const firstEl = focusable[0];
const lastEl = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
}
} else {
if (document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
#handleKeydown(e) {
if (e.key === 'Escape' && this.#options.closeOnEscape) {
e.preventDefault();
this.close();
}
this.#trapFocus(e);
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
open() {
if (this.#isOpen) return;
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
}
// Store last focused element
this.#lastFocusedElement = document.activeElement;
close() {
if (!this.#isOpen) return;
// Track open modals
ModalManager.#openModals.push(this);
// Show modal: remove hidden first, force a reflow so the browser
// records opacity:0 as the start state, then add 'open' to trigger
// the CSS transition. Without the reflow, both class changes are
// batched into one style recalculation and the transition is skipped.
this.#modal.classList.remove('hidden');
void this.#modal.offsetHeight; // Force reflow — do not remove
this.#modal.classList.add('open');
this.#isOpen = true;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Add keyboard listener
const keydownHandler = (e) => this.#handleKeydown(e);
document.addEventListener('keydown', keydownHandler);
this.#eventListeners.push({ el: document, type: 'keydown', handler: keydownHandler });
// Focus first element
if (this.#options.focusFirst) {
requestAnimationFrame(() => {
const focusable = this.#getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
}
});
}
// Callback
if (this.#options.onOpen) {
this.#options.onOpen(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:open'));
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
close() {
if (!this.#isOpen) return;
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Remove from open modals
const index = ModalManager.#openModals.indexOf(this);
if (index > -1) {
ModalManager.#openModals.splice(index, 1);
}
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Start hide transition
this.#modal.classList.remove('open');
this.#isOpen = false;
// Re-add 'hidden' after the CSS transition completes so the modal
// is fully removed from rendering (display:none), not just invisible.
// We use both transitionend and a setTimeout fallback because
// transitionend never fires when prefers-reduced-motion disables transitions.
let hidden = false;
const addHidden = () => {
if (hidden || this.#isOpen) return;
hidden = true;
this.#modal.removeEventListener('transitionend', onTransitionEnd);
this.#modal.classList.add('hidden');
};
const onTransitionEnd = (e) => {
if (e.target !== this.#modal) return;
addHidden();
};
this.#modal.addEventListener('transitionend', onTransitionEnd);
setTimeout(addHidden, 250); // fallback: slightly longer than 200ms transition
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
l => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter(l => l !== keydownListener);
}
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
// Restore body scroll if no modals open
if (ModalManager.#openModals.length === 0) {
document.body.style.overflow = '';
}
isOpen() {
return this.#isOpen;
// Remove keyboard listener
const keydownListener = this.#eventListeners.find(
(l) => l.el === document && l.type === 'keydown'
);
if (keydownListener) {
document.removeEventListener('keydown', keydownListener.handler);
this.#eventListeners = this.#eventListeners.filter((l) => l !== keydownListener);
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
// Restore focus
if (this.#lastFocusedElement && typeof this.#lastFocusedElement.focus === 'function') {
this.#lastFocusedElement.focus();
}
// Callback
if (this.#options.onClose) {
this.#options.onClose(this);
}
// Dispatch custom event
this.#modal.dispatchEvent(new CustomEvent('modal:close'));
}
isOpen() {
return this.#isOpen;
}
destroy() {
// Close if open
if (this.#isOpen) {
this.close();
}
// Remove all event listeners
this.#eventListeners.forEach(({ el, type, handler }) => {
el.removeEventListener(type, handler);
});
this.#eventListeners = [];
}
}
// Export for use in renderer - avoid duplicate declaration
if (typeof window !== 'undefined' && !window.ModalManager) {
window.ModalManager = ModalManager;
window.ModalManager = ModalManager;
}
// CommonJS export