Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/contextmenu/GridContextMenu.js
T

490 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Grid Context Menu - Right-click menu for asset cards
*/
import { buildAssetViewURL } from "../../api/endpoints.js";
import { comfyAlert, comfyConfirm, comfyPrompt } from "../../app/dialogs.js";
import { openInFolder, updateAssetRating, deleteAsset, renameAsset, removeFilepathsFromCollection } from "../../api/client.js";
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../../app/events.js";
import { createTagsEditor } from "../../components/TagsEditor.js";
import { safeDispatchCustomEvent } from "../../utils/events.js";
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
import { getOrCreateMenu, createMenuItem, createMenuSeparator, showMenuAt } from "../../components/contextmenu/MenuCore.js";
import { hideMenu, clearMenu } from "../../components/contextmenu/MenuCore.js";
// Bulk operations removed - using local helpers
const getSelectedAssetIds = (gridContainer) => {
const selected = new Set();
if (!gridContainer) return selected;
try {
const cards = gridContainer.querySelectorAll('.mjr-asset-card.is-selected');
for (const card of cards) {
const id = card.dataset?.mjrAssetId;
if (id) selected.add(String(id));
}
} catch {}
return selected;
};
const getSelectedAssets = (gridContainer) => {
const assets = [];
if (!gridContainer) return assets;
try {
const cards = gridContainer.querySelectorAll(".mjr-asset-card.is-selected");
for (const card of cards) {
const a = card?._mjrAsset;
if (a) assets.push(a);
}
} catch {}
return assets;
};
const getAssetFilepath = (asset) => {
try {
const fp = asset?.filepath || asset?.path || asset?.file_info?.filepath || "";
return fp ? String(fp) : "";
} catch {
return "";
}
};
const clearSelection = (gridContainer) => {
if (!gridContainer) return;
try {
const cards = gridContainer.querySelectorAll('.mjr-asset-card.is-selected');
for (const card of cards) {
card.classList.remove('is-selected');
card.setAttribute('aria-selected', 'false');
}
} catch {}
};
const MENU_SELECTOR = ".mjr-grid-context-menu";
const POPOVER_SELECTOR = ".mjr-grid-popover";
function createMenu() {
return getOrCreateMenu({
selector: MENU_SELECTOR,
className: "mjr-grid-context-menu",
minWidth: 220,
zIndex: 10001,
onHide: null,
});
}
const createItem = createMenuItem;
const separator = createMenuSeparator;
const showAt = showMenuAt;
function getOrCreateRatingSubmenu() {
return getOrCreateMenu({
selector: ".mjr-grid-rating-submenu",
className: "mjr-grid-rating-submenu",
minWidth: 200,
zIndex: 10002,
onHide: null,
});
}
function showSubmenuNextTo(anchorEl, menuEl) {
if (!anchorEl || !menuEl) return;
const rect = anchorEl.getBoundingClientRect();
const x = Math.round(rect.right + 6);
const y = Math.round(rect.top - 4);
showAt(menuEl, x, y);
}
function showTagsPopover(x, y, asset, onChanged) {
try {
const existing = document.querySelector(POPOVER_SELECTOR);
existing?.remove?.();
} catch {}
const pop = document.createElement("div");
pop.className = "mjr-grid-popover";
pop.style.cssText = `
position: fixed;
z-index: 10002;
left: ${x}px;
top: ${y}px;
`;
const ac = new AbortController();
pop._mjrAbortController = ac;
const editor = createTagsEditor(asset, (tags) => {
asset.tags = tags;
safeDispatchCustomEvent(
ASSET_TAGS_CHANGED_EVENT,
{ assetId: String(asset.id), tags },
{ warnPrefix: "[GridContextMenu]" }
);
try {
onChanged?.();
} catch {}
});
pop.appendChild(editor);
document.body.appendChild(pop);
const hide = () => {
try {
pop._mjrAbortController?.abort?.();
} catch {}
try {
pop.remove?.();
} catch {}
};
// Dismiss when clicking outside (so clicking inside input doesn't close it).
try {
document.addEventListener(
"mousedown",
(e) => {
if (!pop.contains(e.target)) hide();
},
{ capture: true, signal: ac.signal }
);
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape") hide();
},
{ capture: true, signal: ac.signal }
);
document.addEventListener("scroll", hide, { capture: true, signal: ac.signal });
} catch {}
// Clamp into viewport
const rect = pop.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let fx = x;
let fy = y;
if (x + rect.width > vw) fx = vw - rect.width - 10;
if (y + rect.height > vh) fy = vh - rect.height - 10;
pop.style.left = `${Math.max(8, fx)}px`;
pop.style.top = `${Math.max(8, fy)}px`;
}
async function setRating(asset, rating, onChanged) {
const assetId = asset?.id;
if (!assetId) return;
try {
const result = await updateAssetRating(assetId, rating);
if (!result?.ok) return;
asset.rating = rating;
safeDispatchCustomEvent(
ASSET_RATING_CHANGED_EVENT,
{ assetId: String(assetId), rating },
{ warnPrefix: "[GridContextMenu]" }
);
onChanged?.();
} catch {}
}
export function bindGridContextMenu({
gridContainer,
getState = () => ({}),
onRequestOpenViewer = () => {},
} = {}) {
if (!gridContainer) return;
if (gridContainer._mjrGridContextMenuBound) return;
gridContainer._mjrGridContextMenuBound = true;
const menu = createMenu();
gridContainer.addEventListener("contextmenu", async (e) => {
// Check if the contextmenu event is on an asset card
const card = e.target.closest(".mjr-asset-card");
if (!card) return;
e.preventDefault();
e.stopPropagation();
const asset = card._mjrAsset;
if (!asset) return;
menu.innerHTML = "";
// Get current selection state
const selectedIds = getSelectedAssetIds(gridContainer);
const isSingleSelected = selectedIds.size === 1 && selectedIds.has(String(asset.id));
const isMultiSelected = selectedIds.size > 1;
const hasSelection = selectedIds.size > 0;
// Open Viewer
menu.appendChild(
createItem("Open Viewer", "pi pi-image", null, () => {
onRequestOpenViewer(asset);
})
);
// Open in Folder
menu.appendChild(
createItem("Open in Folder", "pi pi-folder-open", null, async () => {
if (!asset?.id) return;
const res = await openInFolder(asset.id);
if (!res?.ok) {
await comfyAlert(res?.error || "Failed to open folder.");
}
}, { disabled: !asset?.id })
);
// Copy file path
menu.appendChild(
createItem("Copy file path", "pi pi-copy", null, async () => {
const p = asset?.filepath ? String(asset.filepath) : "";
if (!p) {
await comfyAlert("No file path available for this asset.");
return;
}
try {
await navigator.clipboard.writeText(p);
} catch {}
})
);
// Add to collection (single or multi)
menu.appendChild(
createItem("Add to collection...", "pi pi-bookmark", null, async () => {
try {
menu.style.display = "none";
} catch {}
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
await showAddToCollectionMenu({ x: e.clientX, y: e.clientY, assets: list });
})
);
// Remove from current collection (only when in collection view)
const state = (() => {
try {
return getState?.() || {};
} catch {
return {};
}
})();
const collectionId = String(state?.collectionId || "").trim();
if (collectionId) {
const selectedAssets = getSelectedAssets(gridContainer);
const list = selectedAssets.length ? selectedAssets : [asset];
const filepaths = list.map(getAssetFilepath).filter(Boolean);
const label = filepaths.length > 1 ? `Remove from collection (${filepaths.length})` : "Remove from collection";
menu.appendChild(
createItem(label, "pi pi-bookmark", null, async () => {
try {
menu.style.display = "none";
} catch {}
if (!filepaths.length) {
await comfyAlert("No file path available for this asset.");
return;
}
const confirmed = await comfyConfirm(`Remove ${filepaths.length} item(s) from this collection?`);
if (!confirmed) return;
const res = await removeFilepathsFromCollection(collectionId, filepaths);
if (!res?.ok) {
await comfyAlert(res?.error || "Failed to remove from collection.");
return;
}
// Refresh the collection view (best effort).
try {
gridContainer?.dispatchEvent?.(new CustomEvent("mjr:reload-grid"));
} catch {}
})
);
}
menu.appendChild(separator());
// Edit Tags
menu.appendChild(createItem("Edit Tags…", "pi pi-tags", null, () => {
try {
hideMenu(menu);
} catch {}
showTagsPopover(e.clientX + 6, e.clientY + 6, asset, () => {
// Update card UI after tags change
const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`);
if (card && card._mjrAsset) {
card._mjrAsset.tags = [...asset.tags];
}
});
}));
// Rating submenu (hover)
menu.appendChild(separator());
const ratingRoot = createItem("Set rating", "pi pi-star", "›", () => {}, { disabled: !asset?.id });
ratingRoot.style.cursor = !asset?.id ? "default" : "pointer";
menu.appendChild(ratingRoot);
let hideTimer = null;
const ratingSubmenu = getOrCreateRatingSubmenu();
const closeRatingSubmenu = () => {
try {
hideMenu(ratingSubmenu);
clearMenu(ratingSubmenu);
} catch {}
};
const scheduleClose = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(closeRatingSubmenu, 180);
};
const cancelClose = () => {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = null;
};
const renderRatingSubmenu = () => {
clearMenu(ratingSubmenu);
const stars = (n) => "★".repeat(n) + "☆".repeat(Math.max(0, 5 - n));
for (const n of [5, 4, 3, 2, 1]) {
ratingSubmenu.appendChild(
createItem(stars(n), "pi pi-star", null, async () => {
await setRating(asset, n, () => {
const card = document.querySelector(
`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`
);
if (card && card._mjrAsset) card._mjrAsset.rating = n;
});
closeRatingSubmenu();
try {
hideMenu(menu);
} catch {}
}, { disabled: !asset?.id })
);
}
ratingSubmenu.appendChild(separator());
ratingSubmenu.appendChild(
createItem("Reset rating", "pi pi-star", "0", async () => {
await setRating(asset, 0, () => {
const card = document.querySelector(
`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`
);
if (card && card._mjrAsset) card._mjrAsset.rating = 0;
});
closeRatingSubmenu();
try {
hideMenu(menu);
} catch {}
}, { disabled: !asset?.id })
);
};
ratingRoot.addEventListener("mouseenter", () => {
if (!asset?.id) return;
cancelClose();
renderRatingSubmenu();
showSubmenuNextTo(ratingRoot, ratingSubmenu);
});
ratingRoot.addEventListener("mouseleave", () => {
scheduleClose();
});
ratingSubmenu.addEventListener("mouseenter", () => cancelClose());
ratingSubmenu.addEventListener("mouseleave", () => scheduleClose());
menu.appendChild(separator());
// Rename (single only)
menu.appendChild(
createItem("Rename…", "pi pi-pencil", null, async () => {
if (!asset?.id) return;
const currentName = asset.filename || "";
const newName = await comfyPrompt("Rename file", currentName);
if (!newName || newName === currentName) return;
try {
const result = await renameAsset(asset.id, newName);
if (result?.ok) {
// Update the asset object and card UI
asset.filename = newName;
asset.filepath = asset.filepath.replace(/[^\\/]+$/, newName);
// Update card UI
const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(asset.id)) : String(asset.id)}"]`);
if (card) {
// Update card display
const filenameEl = card.querySelector('.mjr-filename');
if (filenameEl) {
filenameEl.textContent = newName;
}
}
await comfyAlert("File renamed successfully!");
} else {
await comfyAlert(result?.error || "Failed to rename file.");
}
} catch (error) {
await comfyAlert(`Error renaming file: ${error.message}`);
}
}, { disabled: !asset?.id })
);
// Delete (single or multi)
if (isMultiSelected) {
menu.appendChild(
createItem(`Delete ${selectedIds.size} files…`, "pi pi-trash", null, async () => {
const confirmed = await comfyConfirm(`Delete ${selectedIds.size} files? This cannot be undone.`);
if (!confirmed) return;
try {
// Delete each asset individually
let successCount = 0;
let errorCount = 0;
for (const assetId of selectedIds) {
const result = await deleteAsset(assetId);
if (result?.ok) {
successCount++;
// Remove card from DOM
const card = document.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(assetId)}"]`);
if (card) card.remove();
} else {
errorCount++;
}
}
// Clear selection
clearSelection(gridContainer);
if (errorCount === 0) {
await comfyAlert(`${successCount} files deleted successfully!`);
} else {
await comfyAlert(`${successCount} files deleted, ${errorCount} failed.`);
}
} catch (error) {
await comfyAlert(`Error deleting files: ${error.message}`);
}
})
);
} else {
menu.appendChild(
createItem("Delete…", "pi pi-trash", null, async () => {
const confirmed = await comfyConfirm("Delete this file? This cannot be undone.");
if (!confirmed) return;
try {
const result = await deleteAsset(asset.id);
if (result?.ok) {
// Remove the card from DOM
card.remove();
await comfyAlert("File deleted successfully!");
} else {
await comfyAlert(result?.error || "Failed to delete file.");
}
} catch (error) {
await comfyAlert(`Error deleting file: ${error.message}`);
}
}, { disabled: !asset?.id })
);
}
// Bulk operations menu removed
showAt(menu, e.clientX, e.clientY);
});
}