Menus are `position: fixed`, so only the window can clip them, but they were placed at the raw cursor position with a naive flip: near the bottom edge the menu opened below the window (nothing to scroll it into view), and a tall menu that flipped up could land above the top edge. The sidebar folder menu and the view-options menu did no viewport math at all, so right-clicking a folder at the bottom of the tree showed no options. Add a shared positionFixedMenu helper that picks the side with room, clamps on both axes, and caps the height (scrolling internally) when the menu is taller than the window. BaseContextMenu uses it and exposes a prepareMenu hook so per-card entries are toggled before the menu is measured; the Recipe/Lora/Checkpoint/Embedding/Other/Workflow menus used to mutate the menu after positioning and so measured the previous content. Submenus get the same treatment via positionSubmenu. Fixes #1167
142 lines
5.5 KiB
JavaScript
142 lines
5.5 KiB
JavaScript
import { BaseContextMenu } from './BaseContextMenu.js';
|
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
|
import { moveManager } from '../../managers/MoveManager.js';
|
|
import { i18n } from '../../i18n/index.js';
|
|
import { sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
|
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
|
|
|
export class CheckpointContextMenu extends BaseContextMenu {
|
|
constructor() {
|
|
super('checkpointContextMenu', '.model-card');
|
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
|
this.modelType = 'checkpoint';
|
|
this.resetAndReload = resetAndReload;
|
|
|
|
this.initNSFWSelector();
|
|
}
|
|
|
|
// Implementation needed by the mixin
|
|
async saveModelMetadata(filePath, data) {
|
|
return getModelApiClient().saveModelMetadata(filePath, data);
|
|
}
|
|
|
|
prepareMenu(card) {
|
|
this.updateExcludeMenuItem();
|
|
this.updateEnrichMenuItem(card);
|
|
|
|
// Update the "Move to other root" label based on current model type
|
|
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
|
if (moveOtherItem) {
|
|
const currentType = card.dataset.sub_type || 'checkpoint';
|
|
const otherType = currentType === 'checkpoint' ? 'diffusion_model' : 'checkpoint';
|
|
const typeLabel = i18n.t(`checkpoints.modelTypes.${otherType}`);
|
|
moveOtherItem.innerHTML = `<i class="fas fa-exchange-alt"></i> ${i18n.t('checkpoints.contextMenu.moveToOtherTypeFolder', { otherType: typeLabel })}`;
|
|
}
|
|
}
|
|
|
|
handleMenuAction(action) {
|
|
// First try to handle with common actions
|
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
|
return;
|
|
}
|
|
|
|
const apiClient = getModelApiClient();
|
|
|
|
// Otherwise handle checkpoint-specific actions
|
|
switch (action) {
|
|
case 'details':
|
|
// Show checkpoint details
|
|
this.currentCard.click();
|
|
break;
|
|
case 'replace-preview':
|
|
// Add new action for replacing preview images
|
|
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
|
|
break;
|
|
case 'delete':
|
|
showDeleteModal(this.currentCard.dataset.filepath);
|
|
break;
|
|
case 'copyname':
|
|
// Copy checkpoint name
|
|
if (this.currentCard.querySelector('.fa-copy')) {
|
|
this.currentCard.querySelector('.fa-copy').click();
|
|
}
|
|
break;
|
|
case 'sendworkflow':
|
|
// Send checkpoint to workflow (always replace mode)
|
|
this.sendCheckpointToWorkflow();
|
|
break;
|
|
case 'refresh-metadata':
|
|
// Refresh metadata from CivitAI
|
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
|
break;
|
|
case 'move':
|
|
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.sub_type);
|
|
break;
|
|
case 'move-other':
|
|
{
|
|
const currentType = this.currentCard.dataset.sub_type || 'checkpoint';
|
|
const otherType = currentType === 'checkpoint' ? 'diffusion_model' : 'checkpoint';
|
|
moveManager.showMoveModal(this.currentCard.dataset.filepath, otherType);
|
|
}
|
|
break;
|
|
case 'exclude':
|
|
showExcludeModal(this.currentCard.dataset.filepath);
|
|
break;
|
|
case 'restore':
|
|
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async sendCheckpointToWorkflow() {
|
|
const modelPath = this.currentCard.dataset.filepath;
|
|
if (!modelPath) {
|
|
return;
|
|
}
|
|
|
|
const subtype = (this.currentCard.dataset.sub_type || 'checkpoint').toLowerCase();
|
|
const isDiffusionModel = subtype === 'diffusion_model';
|
|
const widgetName = isDiffusionModel ? 'unet_name' : 'ckpt_name';
|
|
const actionTypeText = i18n.t(
|
|
isDiffusionModel ? 'uiHelpers.nodeSelector.diffusionModel' : 'uiHelpers.nodeSelector.checkpoint',
|
|
{},
|
|
isDiffusionModel ? 'Diffusion Model' : 'Checkpoint'
|
|
);
|
|
const successMessage = i18n.t(
|
|
'uiHelpers.workflow.modelUpdated',
|
|
{},
|
|
'Model updated in workflow'
|
|
);
|
|
const failureMessage = i18n.t(
|
|
'uiHelpers.workflow.modelFailed',
|
|
{},
|
|
'Failed to update model node'
|
|
);
|
|
const missingNodesMessage = i18n.t(
|
|
'uiHelpers.workflow.noMatchingNodes',
|
|
{},
|
|
'No compatible nodes available in the current workflow'
|
|
);
|
|
const missingTargetMessage = i18n.t(
|
|
'uiHelpers.workflow.noTargetNodeSelected',
|
|
{},
|
|
'No target node selected'
|
|
);
|
|
|
|
await sendModelPathToWorkflow(modelPath, {
|
|
widgetName,
|
|
collectionType: MODEL_TYPES.CHECKPOINT,
|
|
actionTypeText,
|
|
successMessage,
|
|
failureMessage,
|
|
missingNodesMessage,
|
|
missingTargetMessage,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Mix in shared methods
|
|
Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin);
|