518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
import { buildAssetViewURL, buildDownloadURL } from "../../api/endpoints.js";
|
|
import { comfyPrompt } from "../../app/dialogs.js";
|
|
import { comfyToast } from "../../app/toast.js";
|
|
import { t } from "../../app/i18n.js";
|
|
import {
|
|
openInFolder,
|
|
deleteAsset,
|
|
renameAsset,
|
|
updateAssetRating,
|
|
getViewerInfo,
|
|
} from "../../api/client.js";
|
|
import {
|
|
ASSET_RATING_CHANGED_EVENT,
|
|
ASSET_TAGS_CHANGED_EVENT,
|
|
VIEWER_INFO_REFRESHED_EVENT,
|
|
} from "../../app/events.js";
|
|
import { safeDispatchCustomEvent } from "../../utils/events.js";
|
|
import { showAddToCollectionMenu } from "../collections/contextmenu/addToCollectionMenu.js";
|
|
import { confirmDeletion } from "../../utils/deleteGuard.js";
|
|
import { normalizeRenameFilename, validateFilename } from "../../utils/filenames.js";
|
|
import { reportError } from "../../utils/logging.js";
|
|
import { scheduleRatingUpdate, cancelAllRatingUpdates } from "../contextmenu/ratingUpdater.js";
|
|
import {
|
|
closeAllViewerContextMenus,
|
|
openViewerContextMenu,
|
|
openViewerTagsPopover,
|
|
} from "../contextmenu/viewerContextMenuState.js";
|
|
|
|
/**
|
|
* Viewer-specific shortcut displays
|
|
* These match the shortcuts defined in keyboard.js for the viewer
|
|
*/
|
|
const VIEWER_SHORTCUTS = {
|
|
COPY_PATH: "Ctrl+Shift+C",
|
|
DOWNLOAD: "Ctrl+S",
|
|
OPEN_IN_FOLDER: "Ctrl+Shift+E",
|
|
ADD_TO_COLLECTION: "B",
|
|
EDIT_TAGS: "T",
|
|
RATING_SUBMENU: "1-5",
|
|
RENAME: "F2",
|
|
DELETE: "Del",
|
|
};
|
|
|
|
const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"];
|
|
const viewerContextMenuBindings = new WeakMap();
|
|
|
|
let _nextMenuItemId = 1;
|
|
|
|
function _menuItemId(prefix = "viewer-menu-item") {
|
|
return `${prefix}-${_nextMenuItemId++}`;
|
|
}
|
|
|
|
function createItem(label, iconClass, rightHint, action, options = {}) {
|
|
return {
|
|
id: _menuItemId(),
|
|
type: "item",
|
|
label: String(label || ""),
|
|
iconClass: iconClass ? String(iconClass) : "",
|
|
rightHint: rightHint ? String(rightHint) : "",
|
|
action: typeof action === "function" ? action : null,
|
|
disabled: Boolean(options.disabled),
|
|
closeOnSelect: options.closeOnSelect !== false,
|
|
submenu: Array.isArray(options.submenu) ? options.submenu.filter(Boolean) : null,
|
|
};
|
|
}
|
|
|
|
function createSeparator() {
|
|
return {
|
|
id: _menuItemId("viewer-menu-separator"),
|
|
type: "separator",
|
|
};
|
|
}
|
|
|
|
function _isSafeUrl(url) {
|
|
const value = String(url || "").trim();
|
|
if (!value) return false;
|
|
if (value.startsWith("/")) return true;
|
|
try {
|
|
const parsed = new URL(value);
|
|
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function setRating(asset, rating, onChanged) {
|
|
const assetId = asset?.id;
|
|
try {
|
|
asset.rating = rating;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
onChanged?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
if (assetId) {
|
|
scheduleRatingUpdate(String(assetId), rating, {
|
|
successMessage: rating > 0 ? `Rating set to ${rating} stars` : "Rating cleared",
|
|
errorMessage: "Failed to update rating",
|
|
warnPrefix: "[ViewerContextMenu]",
|
|
onSuccess: () => {
|
|
safeDispatchCustomEvent(
|
|
ASSET_RATING_CHANGED_EVENT,
|
|
{ assetId: String(assetId), rating },
|
|
{ warnPrefix: "[ViewerContextMenu]" },
|
|
);
|
|
},
|
|
onFailure: (error) => {
|
|
reportError(error, "[ViewerContextMenu] Rating update", { showToast: true });
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
updateAssetRating(asset, rating).catch((error) => {
|
|
reportError(error, "[ViewerContextMenu] Rating update", { showToast: true });
|
|
});
|
|
}
|
|
|
|
function formatReadableSize(value) {
|
|
let bytes = Number(value);
|
|
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
let unitIndex = 0;
|
|
while (bytes >= 1024 && unitIndex < SIZE_UNITS.length - 1) {
|
|
bytes /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
const formatted = unitIndex === 0 ? `${Math.round(bytes)}` : bytes.toFixed(2);
|
|
return `${formatted} ${SIZE_UNITS[unitIndex]}`;
|
|
}
|
|
|
|
function createRatingSubmenu(asset, onAssetChanged, canRate) {
|
|
const items = [5, 4, 3, 2, 1].map((rating) =>
|
|
createItem(
|
|
`${rating} Stars`,
|
|
"pi pi-star",
|
|
null,
|
|
async () => {
|
|
setRating(asset, rating, onAssetChanged);
|
|
},
|
|
{ disabled: !canRate },
|
|
),
|
|
);
|
|
items.push(createSeparator());
|
|
items.push(
|
|
createItem(
|
|
t("ctx.resetRating", "Reset rating"),
|
|
"pi pi-star",
|
|
"0",
|
|
async () => {
|
|
setRating(asset, 0, onAssetChanged);
|
|
},
|
|
{ disabled: !canRate },
|
|
),
|
|
);
|
|
return items;
|
|
}
|
|
|
|
function createViewerMenuItems({
|
|
asset,
|
|
event,
|
|
getCurrentViewUrl,
|
|
onAssetChanged,
|
|
}) {
|
|
const viewUrl =
|
|
typeof getCurrentViewUrl === "function"
|
|
? getCurrentViewUrl(asset)
|
|
: buildAssetViewURL(asset);
|
|
const canRate = !!(asset?.id || asset?.filepath);
|
|
|
|
const items = [
|
|
createItem(
|
|
t("ctx.openInNewTab", "Open in New Tab"),
|
|
"pi pi-external-link",
|
|
null,
|
|
async () => {
|
|
if (!_isSafeUrl(viewUrl)) return;
|
|
window.open(viewUrl, "_blank", "noopener,noreferrer");
|
|
},
|
|
),
|
|
createItem(
|
|
t("ctx.copyPath", "Copy path"),
|
|
"pi pi-copy",
|
|
VIEWER_SHORTCUTS.COPY_PATH,
|
|
async () => {
|
|
const path = asset?.filepath ? String(asset.filepath) : "";
|
|
if (!path) {
|
|
comfyToast(t("toast.noFilePath"), "error");
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(path);
|
|
comfyToast(t("toast.pathCopied"), "success", 2000);
|
|
} catch (error) {
|
|
console.error("[ViewerContextMenu] Copy failed:", error);
|
|
comfyToast(t("toast.pathCopyFailed"), "error");
|
|
}
|
|
},
|
|
),
|
|
createItem(
|
|
t("ctx.downloadOriginal", "Download Original"),
|
|
"pi pi-download",
|
|
VIEWER_SHORTCUTS.DOWNLOAD,
|
|
async () => {
|
|
if (!asset || !asset.filepath) return;
|
|
const url = buildDownloadURL(asset.filepath);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = asset.filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
comfyToast(
|
|
t("toast.downloadingFile", "Downloading {filename}...", {
|
|
filename: asset.filename,
|
|
}),
|
|
"info",
|
|
3000,
|
|
);
|
|
},
|
|
{ disabled: !asset?.filepath },
|
|
),
|
|
createItem(
|
|
t("ctx.openInFolder", "Open in folder"),
|
|
"pi pi-folder-open",
|
|
VIEWER_SHORTCUTS.OPEN_IN_FOLDER,
|
|
async () => {
|
|
const res = await openInFolder(asset);
|
|
if (!res?.ok) {
|
|
comfyToast(res?.error || t("toast.openFolderFailed"), "error");
|
|
} else {
|
|
comfyToast(t("toast.openedInFolder"), "info", 2000);
|
|
}
|
|
},
|
|
{ disabled: !(asset?.id || asset?.filepath) },
|
|
),
|
|
createItem(
|
|
t("ctx.addToCollection", "Add to collection"),
|
|
"pi pi-bookmark",
|
|
VIEWER_SHORTCUTS.ADD_TO_COLLECTION,
|
|
async () => {
|
|
try {
|
|
await showAddToCollectionMenu({
|
|
x: event?.clientX,
|
|
y: event?.clientY,
|
|
assets: [asset],
|
|
});
|
|
} catch (error) {
|
|
console.error("[ViewerContextMenu] Add to collection failed:", error);
|
|
}
|
|
},
|
|
),
|
|
createSeparator(),
|
|
createItem(
|
|
t("ctx.editTags", "Edit tags"),
|
|
"pi pi-tags",
|
|
VIEWER_SHORTCUTS.EDIT_TAGS,
|
|
async () => {
|
|
openViewerTagsPopover({
|
|
x: (Number(event?.clientX) || 0) + 6,
|
|
y: (Number(event?.clientY) || 0) + 6,
|
|
asset,
|
|
onChanged: (tags) => {
|
|
asset.tags = tags;
|
|
safeDispatchCustomEvent(
|
|
ASSET_TAGS_CHANGED_EVENT,
|
|
{ assetId: String(asset.id), tags },
|
|
{ warnPrefix: "[ViewerContextMenu]" },
|
|
);
|
|
try {
|
|
onAssetChanged?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
{ closeOnSelect: false },
|
|
),
|
|
createSeparator(),
|
|
createItem(
|
|
t("ctx.setRating", "Set rating"),
|
|
"pi pi-star",
|
|
`${VIEWER_SHORTCUTS.RATING_SUBMENU} >`,
|
|
null,
|
|
{
|
|
disabled: !canRate,
|
|
closeOnSelect: false,
|
|
submenu: createRatingSubmenu(asset, onAssetChanged, canRate),
|
|
},
|
|
),
|
|
createItem(
|
|
t("ctx.refreshMetadata", "Refresh metadata"),
|
|
"pi pi-sync",
|
|
"R",
|
|
async () => {
|
|
if (!asset?.id) return;
|
|
try {
|
|
const res = await getViewerInfo(asset.id, { refresh: true });
|
|
if (!res?.ok || !res?.data) {
|
|
comfyToast(
|
|
res?.error ||
|
|
t("toast.metadataRefreshFailed", "Failed to refresh metadata."),
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
const info = res.data;
|
|
try {
|
|
safeDispatchCustomEvent(
|
|
VIEWER_INFO_REFRESHED_EVENT,
|
|
{ assetId: String(asset.id), info },
|
|
{ warnPrefix: "[ViewerContextMenu]" },
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const parts = [];
|
|
const sizeLabel = formatReadableSize(info?.size_bytes);
|
|
if (sizeLabel) parts.push(sizeLabel);
|
|
if (info?.mime) parts.push(info.mime);
|
|
const suffix = parts.length ? ` (${parts.join(", ")})` : "";
|
|
comfyToast(
|
|
t("toast.metadataRefreshed", "Metadata refreshed{suffix}", { suffix }),
|
|
"success",
|
|
3000,
|
|
);
|
|
} catch (error) {
|
|
reportError(error, "[ViewerContextMenu] Metadata refresh", {
|
|
showToast: true,
|
|
});
|
|
}
|
|
},
|
|
{ disabled: !asset?.id },
|
|
),
|
|
createSeparator(),
|
|
createItem(
|
|
t("ctx.rename", "Rename"),
|
|
"pi pi-pencil",
|
|
VIEWER_SHORTCUTS.RENAME,
|
|
async () => {
|
|
if (!(asset?.id || asset?.filepath)) return;
|
|
const currentName = asset.filename || "";
|
|
const rawInput = await comfyPrompt(
|
|
t("dialog.rename.title", "Rename file"),
|
|
currentName,
|
|
);
|
|
const newName = normalizeRenameFilename(rawInput, currentName);
|
|
if (!newName || newName === currentName) return;
|
|
const validation = validateFilename(newName);
|
|
if (!validation.valid) {
|
|
comfyToast(validation.reason, "error");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const renameResult = await renameAsset(asset, newName);
|
|
if (renameResult?.ok) {
|
|
const fresh = renameResult?.data?.asset;
|
|
if (fresh && typeof fresh === "object") {
|
|
Object.assign(asset, fresh);
|
|
} else {
|
|
asset.filename = newName;
|
|
asset.filepath = asset.filepath.replace(/[^\\/]+$/, newName);
|
|
if (asset.path) {
|
|
asset.path = String(asset.path).replace(/[^\\/]+$/, newName);
|
|
}
|
|
if (asset.file_info && typeof asset.file_info === "object") {
|
|
asset.file_info.filename = newName;
|
|
if (asset.file_info.filepath) {
|
|
asset.file_info.filepath = String(
|
|
asset.file_info.filepath,
|
|
).replace(/[^\\/]+$/, newName);
|
|
}
|
|
if (asset.file_info.path) {
|
|
asset.file_info.path = String(asset.file_info.path).replace(
|
|
/[^\\/]+$/,
|
|
newName,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
comfyToast(t("toast.fileRenamedSuccess"), "success");
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent("mjr:reload-grid", {
|
|
detail: { reason: "viewer-rename" },
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
onAssetChanged?.();
|
|
} else {
|
|
comfyToast(renameResult?.error || t("toast.fileRenameFailed"), "error");
|
|
}
|
|
} catch (error) {
|
|
comfyToast(
|
|
t("toast.errorRenaming", "Error renaming file: {error}", {
|
|
error: error?.message || String(error || ""),
|
|
}),
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
{ disabled: !(asset?.id || asset?.filepath) },
|
|
),
|
|
createItem(
|
|
t("ctx.delete", "Delete"),
|
|
"pi pi-trash",
|
|
VIEWER_SHORTCUTS.DELETE,
|
|
async () => {
|
|
if (!(asset?.id || asset?.filepath)) return;
|
|
const ok = await confirmDeletion(1, asset?.filename);
|
|
if (!ok) return;
|
|
|
|
try {
|
|
const deleteResult = await deleteAsset(asset);
|
|
if (deleteResult?.ok) {
|
|
comfyToast(t("toast.fileDeletedSuccess"), "success");
|
|
onAssetChanged?.();
|
|
} else {
|
|
comfyToast(deleteResult?.error || t("toast.fileDeleteFailed"), "error");
|
|
}
|
|
} catch (error) {
|
|
comfyToast(
|
|
t("toast.errorDeleting", "Error deleting file: {error}", {
|
|
error: error?.message || String(error || ""),
|
|
}),
|
|
"error",
|
|
);
|
|
}
|
|
},
|
|
{ disabled: !(asset?.id || asset?.filepath) },
|
|
),
|
|
];
|
|
|
|
return items;
|
|
}
|
|
|
|
export function bindViewerContextMenu({
|
|
overlayEl,
|
|
getCurrentAsset,
|
|
getCurrentViewUrl,
|
|
onAssetChanged,
|
|
} = {}) {
|
|
if (!overlayEl || typeof getCurrentAsset !== "function") return;
|
|
const existingBinding = viewerContextMenuBindings.get(overlayEl);
|
|
if (typeof existingBinding?.unbind === "function") {
|
|
return existingBinding.unbind;
|
|
}
|
|
|
|
const handler = async (event) => {
|
|
if (!overlayEl.contains(event.target)) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const asset = getCurrentAsset();
|
|
if (!asset) return;
|
|
|
|
openViewerContextMenu({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
items: createViewerMenuItems({
|
|
asset,
|
|
event,
|
|
getCurrentViewUrl,
|
|
onAssetChanged,
|
|
}),
|
|
});
|
|
};
|
|
|
|
try {
|
|
overlayEl.addEventListener("contextmenu", handler);
|
|
} catch (error) {
|
|
console.error("[ViewerContextMenu] Failed to bind:", error);
|
|
}
|
|
|
|
const unbind = () => {
|
|
try {
|
|
overlayEl.removeEventListener("contextmenu", handler);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
cancelAllRatingUpdates();
|
|
const ratingDebounceTimers = globalThis?._ratingDebounceTimers;
|
|
if (ratingDebounceTimers && typeof ratingDebounceTimers.clear === "function") {
|
|
ratingDebounceTimers.clear();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
closeAllViewerContextMenus();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
viewerContextMenuBindings.delete(overlayEl);
|
|
};
|
|
|
|
viewerContextMenuBindings.set(overlayEl, { unbind });
|
|
return unbind;
|
|
}
|
|
|
|
export function unbindViewerContextMenu(overlayEl) {
|
|
const binding = overlayEl ? viewerContextMenuBindings.get(overlayEl) : null;
|
|
try {
|
|
binding?.unbind?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|