Files
ComfyUI-Lora-Manager/static/js/managers/ModalManager.js
T
Will Miao 4938faa049 feat(sidebar): delete folders from the sidebar (#999)
Folders created from the sidebar had no in-app way back out: the only
removal path was to leave ComfyUI, delete the directory by hand and
rescan. A typo'd folder also polluted the move/download destination
picker permanently, since it reads the same all_folders source.

Adds POST /api/lm/{prefix}/delete-folder, restricted to directories
whose subtree holds no model weight files — a folder-level cascade would
bypass the per-model lifecycle bookkeeping (metadata sidecars, previews,
cache entries, pending-delete staging, recipe references). The service
walks the directory itself instead of trusting the possibly stale cache,
reports what it would remove (models / files / subfolders / symlinks),
and refuses library roots, top-level symlinks (shutil.rmtree rejects
those) and folders holding a staged delete, whose manifest would be
invalidated by the move. Symbolic links inside the subtree are counted
but never followed.

ModelScanner.remove_known_folder mirrors add_known_folder: the removed
subtree leaves all_folders while ancestors are kept (every recorded
ancestor exists on disk in its own right), stale cache entries under the
prefix are purged and the folder list recomputed. The handler broadcasts
models_changed so destination pickers drop the folder too.

The sidebar entry is a destructive context-menu item. The modal opens in
a confirm state for model-free folders and an explanatory one when the
subtree still holds models, decided from the models-only set that already
dims empty nodes; a stale tree is caught by the 409 not_empty/busy
conflict. Truly empty folders get the existing 20s undo affordance,
implemented by re-creating the directory.
2026-09-15 20:05:10 +08:00

548 lines
20 KiB
JavaScript

export class ModalManager {
constructor() {
this.modals = new Map();
this.scrollPosition = 0;
this.currentOpenModal = null; // Track currently open modal
this.mouseDownOnBackground = false; // Track if mousedown happened on modal background
}
initialize() {
if (this.initialized) return;
this.boundHandleEscape = this.handleEscape.bind(this);
// Register all modals - only if they exist in the current page
const modelModal = document.getElementById('modelModal');
if (modelModal) {
this.registerModal('modelModal', {
element: modelModal,
onClose: () => {
this.getModal('modelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add checkpointDownloadModal registration
const checkpointDownloadModal = document.getElementById('checkpointDownloadModal');
if (checkpointDownloadModal) {
this.registerModal('checkpointDownloadModal', {
element: checkpointDownloadModal,
onClose: () => {
this.getModal('checkpointDownloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
const deleteModal = document.getElementById('deleteModal');
if (deleteModal) {
this.registerModal('deleteModal', {
element: deleteModal,
onClose: () => {
this.getModal('deleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add excludeModal registration
const excludeModal = document.getElementById('excludeModal');
if (excludeModal) {
this.registerModal('excludeModal', {
element: excludeModal,
onClose: () => {
this.getModal('excludeModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add downloadModal registration
const downloadModal = document.getElementById('downloadModal');
if (downloadModal) {
this.registerModal('downloadModal', {
element: downloadModal,
onClose: () => {
this.getModal('downloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add settingsModal registration
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
this.registerModal('settingsModal', {
element: settingsModal,
onClose: () => {
this.getModal('settingsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
const doctorModal = document.getElementById('doctorModal');
if (doctorModal) {
this.registerModal('doctorModal', {
element: doctorModal,
onClose: () => {
this.getModal('doctorModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add moveModal registration
const moveModal = document.getElementById('moveModal');
if (moveModal) {
this.registerModal('moveModal', {
element: moveModal,
onClose: () => {
this.getModal('moveModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add supportModal registration
const supportModal = document.getElementById('supportModal');
if (supportModal) {
this.registerModal('supportModal', {
element: supportModal,
onClose: () => {
this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add updateModal registration
const updateModal = document.getElementById('updateModal');
if (updateModal) {
this.registerModal('updateModal', {
element: updateModal,
onClose: () => {
this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add importModal registration
const importModal = document.getElementById('importModal');
if (importModal) {
this.registerModal('importModal', {
element: importModal,
onClose: () => {
this.getModal('importModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add batchImportModal registration.
// Deliberately no closeOnOutsideClick: batch import is a stateful,
// multi-step workflow (input -> progress -> results) that runs a
// long-lived background operation. A stray backdrop click would
// dismiss the modal while the import keeps running, leaving users
// unable to tell what is still happening (issue #1084). Close is
// available via the explicit X button / Cancel instead.
const batchImportModal = document.getElementById('batchImportModal');
if (batchImportModal) {
this.registerModal('batchImportModal', {
element: batchImportModal,
onClose: () => {
this.getModal('batchImportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add recipeModal registration
const recipeModal = document.getElementById('recipeModal');
if (recipeModal) {
this.registerModal('recipeModal', {
element: recipeModal,
onClose: () => {
// Stop any playing video
const video = recipeModal.querySelector('video');
if (video) {
video.pause();
video.currentTime = 0;
}
this.getModal('recipeModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add duplicateDeleteModal registration
const duplicateDeleteModal = document.getElementById('duplicateDeleteModal');
if (duplicateDeleteModal) {
this.registerModal('duplicateDeleteModal', {
element: duplicateDeleteModal,
onClose: () => {
this.getModal('duplicateDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add modelDuplicateDeleteModal registration
const modelDuplicateDeleteModal = document.getElementById('modelDuplicateDeleteModal');
if (modelDuplicateDeleteModal) {
this.registerModal('modelDuplicateDeleteModal', {
element: modelDuplicateDeleteModal,
onClose: () => {
this.getModal('modelDuplicateDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add clearCacheModal registration
const clearCacheModal = document.getElementById('clearCacheModal');
if (clearCacheModal) {
this.registerModal('clearCacheModal', {
element: clearCacheModal,
onClose: () => {
this.getModal('clearCacheModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add bulkDeleteModal registration
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
if (bulkDeleteModal) {
this.registerModal('bulkDeleteModal', {
element: bulkDeleteModal,
onClose: () => {
this.getModal('bulkDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add checkUpdatesConfirmModal registration
const checkUpdatesConfirmModal = document.getElementById('checkUpdatesConfirmModal');
if (checkUpdatesConfirmModal) {
this.registerModal('checkUpdatesConfirmModal', {
element: checkUpdatesConfirmModal,
onClose: () => {
this.getModal('checkUpdatesConfirmModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add deleteFolderModal registration
const deleteFolderModal = document.getElementById('deleteFolderModal');
if (deleteFolderModal) {
this.registerModal('deleteFolderModal', {
element: deleteFolderModal,
onClose: () => {
this.getModal('deleteFolderModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add helpModal registration
const helpModal = document.getElementById('helpModal');
if (helpModal) {
this.registerModal('helpModal', {
element: helpModal,
onClose: () => {
this.getModal('helpModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add relinkCivitaiModal registration
const relinkCivitaiModal = document.getElementById('relinkCivitaiModal');
if (relinkCivitaiModal) {
this.registerModal('relinkCivitaiModal', {
element: relinkCivitaiModal,
onClose: () => {
this.getModal('relinkCivitaiModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add linkHfModal registration
const linkHfModal = document.getElementById('linkHfModal');
if (linkHfModal) {
this.registerModal('linkHfModal', {
element: linkHfModal,
onClose: () => {
this.getModal('linkHfModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add exampleAccessModal registration
const exampleAccessModal = document.getElementById('exampleAccessModal');
if (exampleAccessModal) {
this.registerModal('exampleAccessModal', {
element: exampleAccessModal,
onClose: () => {
this.getModal('exampleAccessModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add bulkAddTagsModal registration
const bulkAddTagsModal = document.getElementById('bulkAddTagsModal');
if (bulkAddTagsModal) {
this.registerModal('bulkAddTagsModal', {
element: bulkAddTagsModal,
onClose: () => {
this.getModal('bulkAddTagsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register bulkBaseModelModal
const bulkBaseModelModal = document.getElementById('bulkBaseModelModal');
if (bulkBaseModelModal) {
this.registerModal('bulkBaseModelModal', {
element: bulkBaseModelModal,
onClose: () => {
this.getModal('bulkBaseModelModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register bulkDownloadMissingLorasModal
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
if (bulkDownloadMissingLorasModal) {
this.registerModal('bulkDownloadMissingLorasModal', {
element: bulkDownloadMissingLorasModal,
onClose: () => {
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register resolveFilenameConflictsModal
const resolveFilenameConflictsModal = document.getElementById('resolveFilenameConflictsModal');
if (resolveFilenameConflictsModal) {
this.registerModal('resolveFilenameConflictsModal', {
element: resolveFilenameConflictsModal,
onClose: () => {
this.getModal('resolveFilenameConflictsModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register rematchOptionsModal
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
if (rematchOptionsModal) {
this.registerModal('rematchOptionsModal', {
element: rematchOptionsModal,
onClose: () => {
this.getModal('rematchOptionsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
registerModal(id, config) {
this.modals.set(id, {
element: config.element,
onClose: config.onClose,
isOpen: false
});
// Add click outside handler if specified in config
if (config.closeOnOutsideClick) {
// Track mousedown on modal background
config.element.addEventListener('mousedown', (e) => {
if (e.target === config.element) {
this.mouseDownOnBackground = true;
} else {
this.mouseDownOnBackground = false;
}
});
// Only close if mouseup is also on the background
config.element.addEventListener('mouseup', (e) => {
if (e.target === config.element && this.mouseDownOnBackground) {
this.closeModal(id);
}
// Reset flag regardless of target
this.mouseDownOnBackground = false;
});
// Cancel the flag if mouse leaves the document entirely
document.addEventListener('mouseleave', () => {
this.mouseDownOnBackground = false;
});
}
}
getModal(id) {
return this.modals.get(id);
}
// Check if any modal is currently open
isAnyModalOpen() {
for (const [id, modal] of this.modals) {
if (modal.isOpen) {
return id;
}
}
return null;
}
showModal(id, content = null, onCloseCallback = null, cleanupCallback = null) {
const modal = this.getModal(id);
if (!modal) return;
// Close any open modal before showing the new one
const openModalId = this.isAnyModalOpen();
if (openModalId && openModalId !== id) {
this.closeModal(openModalId);
}
if (content) {
modal.element.innerHTML = content;
}
// Store callback
modal.onCloseCallback = onCloseCallback;
modal.cleanupCallback = cleanupCallback;
// Store current scroll position before showing modal
this.scrollPosition = window.scrollY;
if (
id === "deleteModal" ||
id === "excludeModal" ||
id === "duplicateDeleteModal" ||
id === "modelDuplicateDeleteModal" ||
id === "clearCacheModal" ||
id === "bulkDeleteModal" ||
id === "checkUpdatesConfirmModal" ||
id === "deleteFolderModal" ||
id === "resolveFilenameConflictsModal"
) {
modal.element.classList.add("show");
} else {
modal.element.style.display = "block";
}
modal.isOpen = true;
this.currentOpenModal = id; // Update currently open modal
document.body.style.top = `-${this.scrollPosition}px`;
document.body.classList.add('modal-open');
modal.restoreFocusTo = null;
if (this._isDeleteConfirmModal(modal.element)) {
const activeElement = document.activeElement;
modal.restoreFocusTo = activeElement && activeElement !== document.body
? activeElement
: null;
modal.element.querySelector('.cancel-btn')?.focus();
}
}
// Several non-delete modals share the delete-modal styling class, so an
// actual .delete-btn is required before focus is moved to Cancel.
_isDeleteConfirmModal(element) {
return element.classList.contains('delete-modal') &&
Boolean(element.querySelector('.delete-btn'));
}
closeModal(id) {
const modal = this.getModal(id);
if (!modal) return;
modal.onClose();
modal.isOpen = false;
// Clear current open modal if this is the one being closed
if (this.currentOpenModal === id) {
this.currentOpenModal = null;
}
// Remove fixed positioning and restore scroll position
document.body.classList.remove('modal-open');
document.body.style.top = '';
window.scrollTo(0, this.scrollPosition);
// Execute onClose callback if exists
if (modal.onCloseCallback) {
modal.onCloseCallback();
modal.onCloseCallback = null;
}
if (modal.cleanupCallback) {
modal.cleanupCallback();
modal.cleanupCallback = null;
}
if (modal.restoreFocusTo) {
if (modal.restoreFocusTo.isConnected) {
modal.restoreFocusTo.focus();
}
modal.restoreFocusTo = null;
}
}
handleEscape(e) {
if (e.key === 'Escape') {
// Close the current open modal if it exists
if (this.currentOpenModal) {
this.closeModal(this.currentOpenModal);
}
}
}
toggleModal(id, content = null, onCloseCallback = null) {
const modal = this.getModal(id);
if (!modal) return;
// If this modal is already open, close it
if (modal.isOpen) {
this.closeModal(id);
return;
}
// Otherwise, show the modal
this.showModal(id, content, onCloseCallback);
}
}
// Create and export a singleton instance
export const modalManager = new ModalManager();