Files
Will Miao 0ec714c122 fix(context-menu): keep popup menus inside the viewport
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
2026-10-31 11:09:50 +08:00

135 lines
5.4 KiB
JavaScript

import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { copyWorkflowToClipboard, restoreWorkflow, sendWorkflowToComfy, updateWorkflowFromComfy } from '../../utils/workflowActions.js';
import { state } from '../../state/index.js';
export class WorkflowContextMenu extends BaseContextMenu {
constructor() {
super('workflowContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'workflows';
this.resetAndReload = resetAndReload;
this.initNSFWSelector();
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
prepareMenu(card) {
// Standalone has no running ComfyUI to push a workflow into; the
// copy-to-clipboard action covers that mode instead.
const sendItem = this.menu?.querySelector('[data-action="send-to-comfy"]');
const updateItem = this.menu?.querySelector('[data-action="update-from-comfy"]');
const standalone = state?.global?.settings?.standalone_mode;
if (sendItem) {
sendItem.style.display = standalone ? 'none' : '';
}
if (updateItem) {
if (standalone) {
updateItem.style.display = 'none';
} else {
updateItem.style.display = '';
this._refreshUpdateFromComfyItem(card, updateItem);
}
}
const restoreItem = this.menu?.querySelector('[data-action="restore-download"]');
if (restoreItem) {
restoreItem.classList.toggle('disabled', !this._isRestorable(card));
}
}
/**
* Restore is available for container artifacts (re-extracted locally from
* the pristine archive) and for CivitAI-sourced .json artifacts
* (re-downloaded from the recorded version). Local-only .json files have
* no restore source; when the item is not in the loaded window we stay
* enabled and let the backend report "no_source".
*/
_isRestorable(card) {
const filePath = card?.dataset?.filepath;
if (!filePath) return false;
if (!filePath.toLowerCase().endsWith('.json')) return true;
const item = state.virtualScroller?.items?.find((entry) => entry.file_path === filePath);
return item ? !!item.from_civitai : true;
}
/**
* Enable "Update from ComfyUI" only when this workflow is the active
* graph in a connected ComfyUI tab and carries unsaved edits. The state
* is pushed by the ComfyUI-side extension; the item starts disabled and
* flips when the answer arrives while the menu is still open.
*/
async _refreshUpdateFromComfyItem(card, item) {
item.classList.add('disabled');
const filePath = card?.dataset?.filepath;
if (!filePath) return;
try {
const states = await getModelApiClient().fetchComfyuiState();
// Menu may have been closed/re-opened for another card meanwhile.
if (this.currentCard !== card) return;
const linked = states.some(
(entry) => entry.file_path === filePath && entry.dirty
);
item.classList.toggle('disabled', !linked);
} catch (_error) {
// State endpoint unavailable (older backend): keep disabled.
}
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
const apiClient = getModelApiClient();
// Otherwise handle workflow-specific actions
switch(action) {
case 'details':
// Show workflow details
this.currentCard.click();
break;
case 'send-to-comfy':
sendWorkflowToComfy(this.currentCard.dataset.filepath);
break;
case 'update-from-comfy':
updateWorkflowFromComfy(this.currentCard.dataset.filepath);
break;
case 'copy-workflow':
copyWorkflowToClipboard(this.currentCard.dataset.filepath);
break;
case 'restore-download':
restoreWorkflow(this.currentCard.dataset.filepath);
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 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
}
}
async sendToComfy(filePath) {
await sendWorkflowToComfy(filePath);
}
}
// Mix in shared methods
Object.assign(WorkflowContextMenu.prototype, ModelContextMenuMixin);