1106 lines
39 KiB
TypeScript
1106 lines
39 KiB
TypeScript
import { disposeGrid, loadAssetsFromList } from "../../grid/gridApi.js";
|
|
import {
|
|
loadMajoorSettings,
|
|
saveMajoorSettings,
|
|
applySettingsToConfig,
|
|
} from "../../../app/settings/settingsCore.js";
|
|
import { EVENTS } from "../../../app/events.js";
|
|
import { t } from "../../../app/i18n.js";
|
|
import { setSelectionIds, getSelectedIdSet } from "../../grid/GridSelectionManager.js";
|
|
import { createRatingBadge } from "../../../components/Badges.js";
|
|
import { get } from "../../../api/client.js";
|
|
import { buildListURL } from "../../../api/endpoints.js";
|
|
import { createPopoverManager } from "../../panel/views/popoverManager.js";
|
|
import { APP_CONFIG } from "../../../app/config.js";
|
|
import { bindGridContextMenu, showTagsPopover } from "../../contextmenu/GridContextMenu.js";
|
|
import { createRatingHotkeysController } from "../../panel/controllers/ratingHotkeysController.js";
|
|
import { installGridKeyboard } from "../../grid/GridKeyboard.js";
|
|
import { bindCardOverlayButton } from "../../grid/StackGroupCards.js";
|
|
import { createMjrApp } from "../../../vue/createVueApp.js";
|
|
import VirtualAssetGridHost from "../../../vue/components/grid/VirtualAssetGridHost.vue";
|
|
|
|
const FEED_FETCH_LIMIT = 240;
|
|
const FEED_PAGE_SIZE = 120;
|
|
const FEED_PUSH_RENDER_DEBOUNCE_MS = 80;
|
|
let viewerOpenRequestModulePromise: Promise<typeof import("../../viewer/viewerOpenRequest.js")> | null = null;
|
|
let floatingViewerManagerModulePromise: Promise<typeof import("../../viewer/floatingViewerManager.js")> | null = null;
|
|
|
|
function loadViewerOpenRequestModule() {
|
|
if (!viewerOpenRequestModulePromise) {
|
|
viewerOpenRequestModulePromise = import("../../viewer/viewerOpenRequest.js");
|
|
}
|
|
return viewerOpenRequestModulePromise;
|
|
}
|
|
|
|
function loadFloatingViewerManagerModule() {
|
|
if (!floatingViewerManagerModulePromise) {
|
|
floatingViewerManagerModulePromise = import("../../viewer/floatingViewerManager.js");
|
|
}
|
|
return floatingViewerManagerModulePromise;
|
|
}
|
|
|
|
function _createFeedMemberCard(asset: any) {
|
|
const card = document.createElement("button");
|
|
card.type = "button";
|
|
card.className = "mjr-asset-card mjr-card mjr-feed-group-member-card";
|
|
card.style.cssText =
|
|
"display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-end;min-height:110px;padding:8px;border:1px solid var(--mjr-border,#444);border-radius:8px;background:var(--mjr-surface,#202020);color:inherit;text-align:left;overflow:hidden;";
|
|
try {
|
|
card.dataset.mjrAssetId = String(asset?.id || "");
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const name = document.createElement("div");
|
|
name.className = "mjr-feed-group-member-name";
|
|
name.style.cssText = "font-size:11px;font-weight:600;line-height:1.3;word-break:break-word;";
|
|
name.textContent = String(asset?.filename || "Asset");
|
|
card.appendChild(name);
|
|
return card;
|
|
}
|
|
|
|
function _getRenderedCards(grid: any) {
|
|
try {
|
|
if (typeof grid?._mjrGetRenderedCards === "function") {
|
|
const cards = grid._mjrGetRenderedCards();
|
|
if (Array.isArray(cards)) return cards;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
return Array.from(grid?.querySelectorAll?.(".mjr-asset-card") || []);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function _applyFeedSelection(grid: any, ids: any, activeId = "") {
|
|
const nextIds = Array.isArray(ids) ? ids.map(String).filter(Boolean) : [];
|
|
return setSelectionIds(
|
|
grid,
|
|
nextIds,
|
|
{ activeId: activeId || nextIds[0] || "" },
|
|
_getRenderedCards,
|
|
);
|
|
}
|
|
|
|
function _bindFeedSelection(host: any) {
|
|
const grid = host?.grid;
|
|
if (!grid) return;
|
|
const onClick = (event: any) => {
|
|
if (event.defaultPrevented) return;
|
|
const card = event.target?.closest?.(".mjr-asset-card");
|
|
if (!card || !grid.contains(card)) return;
|
|
const interactive = event.target?.closest?.("a, button, input, textarea, select, label");
|
|
if (interactive && interactive !== card && card.contains(interactive)) return;
|
|
const clickedId = String(card.dataset?.mjrAssetId || "").trim();
|
|
if (!clickedId) return;
|
|
|
|
try {
|
|
(window as any).__MJR_LAST_SELECTION_GRID__ = grid;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const cards = _getRenderedCards(grid);
|
|
const clickedIndex = cards.indexOf(card);
|
|
const existing = getSelectedIdSet(grid);
|
|
|
|
if (event.shiftKey && clickedIndex >= 0) {
|
|
const anchorIndex = Number.isFinite(Number(grid._mjrLastSelectedIndex))
|
|
? Number(grid._mjrLastSelectedIndex)
|
|
: clickedIndex;
|
|
const start = Math.min(anchorIndex, clickedIndex);
|
|
const end = Math.max(anchorIndex, clickedIndex);
|
|
const rangeIds = cards
|
|
.slice(start, end + 1)
|
|
.map((el) => String(el?.dataset?.mjrAssetId || "").trim())
|
|
.filter(Boolean);
|
|
_applyFeedSelection(grid, rangeIds, clickedId);
|
|
grid._mjrLastSelectedIndex = clickedIndex;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
if (existing.has(clickedId)) existing.delete(clickedId);
|
|
else existing.add(clickedId);
|
|
const nextIds = Array.from(existing);
|
|
_applyFeedSelection(
|
|
grid,
|
|
nextIds,
|
|
existing.has(clickedId) ? clickedId : nextIds[0] || "",
|
|
);
|
|
if (clickedIndex >= 0) grid._mjrLastSelectedIndex = clickedIndex;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
_applyFeedSelection(grid, [clickedId], clickedId);
|
|
if (clickedIndex >= 0) grid._mjrLastSelectedIndex = clickedIndex;
|
|
try {
|
|
card.focus?.({ preventScroll: true });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
|
|
grid.addEventListener("click", onClick, true);
|
|
host._disposeSelection = () => {
|
|
try {
|
|
grid.removeEventListener("click", onClick, true);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
}
|
|
|
|
const FEED_TAB_ID = "majoor-generated-feed";
|
|
const FEED_STATE = {
|
|
hosts: new Set(),
|
|
pendingAssets: new Map(),
|
|
};
|
|
|
|
function _getAssetGroupKey(asset: any) {
|
|
if (!APP_CONFIG.EXECUTION_GROUPING_ENABLED) {
|
|
return _assetKey(asset);
|
|
}
|
|
const stackId = String(asset?.stack_id || "").trim();
|
|
if (stackId) return `stack:${stackId}`;
|
|
const jobId = String(asset?.job_id || "").trim();
|
|
if (jobId) return `job:${jobId}`;
|
|
return _assetKey(asset);
|
|
}
|
|
|
|
function _groupFeedAssets(assets: any) {
|
|
const groups = new Map();
|
|
for (const raw of Array.isArray(assets) ? assets : []) {
|
|
const asset = _normalizeAsset(raw);
|
|
if (!asset) continue;
|
|
const groupKey = _getAssetGroupKey(asset);
|
|
let entry = groups.get(groupKey);
|
|
if (!entry) {
|
|
entry = { key: groupKey, members: [] };
|
|
groups.set(groupKey, entry);
|
|
}
|
|
entry.members.push(asset);
|
|
}
|
|
|
|
const items: any[] = [];
|
|
for (const entry of groups.values()) {
|
|
entry.members.sort((a: any, b: any) => {
|
|
const am = Number(a?.mtime || a?.created_at || 0) || 0;
|
|
const bm = Number(b?.mtime || b?.created_at || 0) || 0;
|
|
return bm - am;
|
|
});
|
|
const cover = entry.members[0];
|
|
items.push({
|
|
...cover,
|
|
_mjrFeedGroupKey: entry.key,
|
|
_mjrFeedGroupCount: entry.members.length,
|
|
_mjrFeedGroupAssets: entry.members.slice(),
|
|
});
|
|
}
|
|
|
|
items.sort((a, b) => {
|
|
const am = Number(a?.mtime || a?.created_at || 0) || 0;
|
|
const bm = Number(b?.mtime || b?.created_at || 0) || 0;
|
|
return bm - am;
|
|
});
|
|
return items;
|
|
}
|
|
|
|
function _getHostAssets(host: any) {
|
|
return Array.from(host?.assetsByKey?.values?.() || []) as any[];
|
|
}
|
|
|
|
function _normalizeAssetIdentityPart(value: any) {
|
|
return String(value ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function _isLivePlaceholderAsset(asset: any) {
|
|
return (
|
|
asset?._mjrLivePlaceholder === true ||
|
|
asset?.is_live_placeholder === true ||
|
|
String(asset?.id || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.startsWith("live:")
|
|
);
|
|
}
|
|
|
|
function _clearLivePlaceholderState(asset: any) {
|
|
if (!asset || typeof asset !== "object") return asset;
|
|
try {
|
|
delete asset._mjrLivePlaceholder;
|
|
delete asset._mjrLiveStatus;
|
|
delete asset._mjrLiveLabel;
|
|
delete asset.is_live_placeholder;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return asset;
|
|
}
|
|
|
|
export function getFeedAssetIdentityKey(asset: Record<string, any> | null): string {
|
|
if (!asset || typeof asset !== "object") return "";
|
|
const type = _normalizeAssetIdentityPart(asset?.type || asset?.source || "output");
|
|
const rootId = _normalizeAssetIdentityPart(asset?.root_id || asset?.custom_root_id || "");
|
|
const subfolder = _normalizeAssetIdentityPart(asset?.subfolder || "");
|
|
const filename = _normalizeAssetIdentityPart(asset?.filename || "");
|
|
if (filename) return `${type}|${rootId}|${subfolder}|${filename}`;
|
|
const filepath = _normalizeAssetIdentityPart(
|
|
asset?.filepath || asset?.path || asset?.fullpath || asset?.full_path || "",
|
|
);
|
|
if (!filepath) return "";
|
|
return `${type}|${rootId}|path|${filepath}`;
|
|
}
|
|
|
|
export function mergeFeedAssetIntoMap(assetsByKey: Map<string, any>, asset: Record<string, any> | null): Record<string, any> | null {
|
|
const normalized = _normalizeAsset(asset);
|
|
if (!assetsByKey || typeof assetsByKey.set !== "function" || !normalized) return null;
|
|
const primaryKey = _assetKey(normalized);
|
|
const identity = getFeedAssetIdentityKey(normalized);
|
|
let existingKey = "";
|
|
if (identity) {
|
|
for (const [key, existingAsset] of assetsByKey.entries()) {
|
|
if (getFeedAssetIdentityKey(existingAsset) === identity) {
|
|
existingKey = key;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const existing = existingKey ? assetsByKey.get(existingKey) : null;
|
|
const merged = existing ? { ...(existing as Record<string, any>), ...normalized } : normalized;
|
|
if (!_isLivePlaceholderAsset(normalized)) _clearLivePlaceholderState(merged);
|
|
if (existingKey && existingKey !== primaryKey) assetsByKey.delete(existingKey);
|
|
assetsByKey.set(primaryKey, merged);
|
|
return merged;
|
|
}
|
|
|
|
function _storeHostAsset(host: any, asset: any) {
|
|
if (!host) return null;
|
|
if (!host.assetsByKey) host.assetsByKey = new Map();
|
|
return mergeFeedAssetIntoMap(host.assetsByKey, asset);
|
|
}
|
|
|
|
function _scheduleHostRender(host: any) {
|
|
if (!host?.grid || !host.loaded) return;
|
|
if (host._renderTimer) return;
|
|
host._renderTimer = setTimeout(() => {
|
|
host._renderTimer = null;
|
|
void _renderHostAssets(host);
|
|
}, FEED_PUSH_RENDER_DEBOUNCE_MS);
|
|
}
|
|
|
|
function _openGroupViewer(host: any, groupedAsset: any, startIndex = 0) {
|
|
const assets = Array.isArray(groupedAsset?._mjrFeedGroupAssets)
|
|
? groupedAsset._mjrFeedGroupAssets.filter(Boolean)
|
|
: [groupedAsset].filter(Boolean);
|
|
if (!assets.length) return;
|
|
const safeIndex = Math.max(0, Math.min(startIndex, assets.length - 1));
|
|
const activeAsset = assets[safeIndex] || null;
|
|
const activeId = String(activeAsset?.id || "").trim();
|
|
if (host?.grid && activeId) {
|
|
try {
|
|
(window as any).__MJR_LAST_SELECTION_GRID__ = host.grid;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
_applyFeedSelection(host.grid, [activeId], activeId);
|
|
}
|
|
void loadViewerOpenRequestModule()
|
|
.then((mod) => mod.requestViewerOpen({ assets, index: safeIndex }))
|
|
.catch((e) => console.debug?.(e));
|
|
}
|
|
|
|
function _buildGroupPopover(host: any, groupedAsset: any) {
|
|
const popover = document.createElement("div");
|
|
popover.className = "mjr-popover mjr-feed-group-popover";
|
|
popover.style.cssText = "display:none; width:min(440px, 92vw); padding:10px;";
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "mjr-feed-group-popover-title";
|
|
title.textContent = t("bottomFeed.groupTitle", "Generation group");
|
|
title.style.cssText = "font-size:12px; font-weight:700; opacity:0.92; margin-bottom:8px;";
|
|
popover.appendChild(title);
|
|
|
|
const grid = document.createElement("div");
|
|
grid.className = "mjr-feed-group-popover-grid";
|
|
grid.style.cssText =
|
|
"display:grid; grid-template-columns:repeat(auto-fill, minmax(110px, 1fr)); gap:8px; max-height:360px; overflow-y:auto; overscroll-behavior:contain;";
|
|
|
|
const assets = Array.isArray(groupedAsset?._mjrFeedGroupAssets)
|
|
? groupedAsset._mjrFeedGroupAssets
|
|
: [];
|
|
for (let index = 0; index < assets.length; index += 1) {
|
|
const member = assets[index];
|
|
const card = _createFeedMemberCard(member);
|
|
card.classList.add("mjr-feed-group-member-card");
|
|
if (index === 0) card.classList.add("mjr-feed-group-member-card--cover");
|
|
card.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
host.popoverManager?.close?.(popover);
|
|
_openGroupViewer(host, groupedAsset, index);
|
|
});
|
|
grid.appendChild(card);
|
|
}
|
|
|
|
popover.appendChild(grid);
|
|
host.root.appendChild(popover);
|
|
return popover;
|
|
}
|
|
|
|
function _decorateFeedCard(host: any, card: any, groupedAsset: any) {
|
|
if (!card || !groupedAsset) return;
|
|
const count = Number(groupedAsset?._mjrFeedGroupCount || 0) || 0;
|
|
if (count <= 1) return;
|
|
|
|
card.dataset.mjrFeedGrouped = "true";
|
|
card.dataset.mjrFeedGroupCount = String(count);
|
|
card.dataset.mjrStacked = "true";
|
|
card.dataset.mjrStackCount = String(count);
|
|
|
|
if (card.querySelector(".mjr-feed-group-button")) return;
|
|
|
|
const badge = document.createElement("button");
|
|
badge.type = "button";
|
|
badge.className = "mjr-feed-group-button";
|
|
badge.title = t("bottomFeed.groupOpen", "Show other assets from this generation");
|
|
badge.setAttribute("aria-label", `${count} assets`);
|
|
const icon = document.createElement("span");
|
|
icon.className = "mjr-feed-group-button-icon pi pi-clone";
|
|
badge.appendChild(icon);
|
|
const countEl = document.createElement("span");
|
|
countEl.className = "mjr-feed-group-button-count";
|
|
countEl.textContent = String(count);
|
|
badge.appendChild(countEl);
|
|
badge.style.cssText = "position:absolute; top:34px; right:6px; z-index:11;";
|
|
bindCardOverlayButton(badge);
|
|
|
|
const popover = _buildGroupPopover(host, groupedAsset);
|
|
badge.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
host.popoverManager?.toggle?.(popover, badge);
|
|
});
|
|
|
|
card.appendChild(badge);
|
|
}
|
|
|
|
function _enhanceGroupedCards(host: any, groupedAssets: any) {
|
|
const groupedById = new Map<string, any>(
|
|
(Array.isArray(groupedAssets) ? groupedAssets : [])
|
|
.map((asset): [string, any] => [String(asset?.id || "").trim(), asset])
|
|
.filter(([id]) => !!id),
|
|
);
|
|
|
|
for (const card of _getRenderedCards(host?.grid)) {
|
|
const assetId = String(card?.dataset?.mjrAssetId || "").trim();
|
|
const groupedAsset = groupedById.get(assetId);
|
|
if (!groupedAsset) continue;
|
|
_decorateFeedCard(host, card, groupedAsset);
|
|
}
|
|
}
|
|
|
|
async function _renderHostAssets(host: any) {
|
|
if (!host?.grid) return;
|
|
try {
|
|
host.popoverManager?.closeAll?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
const stale = host.root?.querySelectorAll?.(".mjr-feed-group-popover") || [];
|
|
for (const el of stale) el.remove?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const groupedAssets = _groupFeedAssets(_getHostAssets(host));
|
|
const res = await loadAssetsFromList(host.grid, groupedAssets, {
|
|
title: t("bottomFeed.title", "Generated Feed"),
|
|
reset: true,
|
|
});
|
|
_enhanceGroupedCards(host, groupedAssets);
|
|
const count = Number((res as any)?.count || 0);
|
|
if (count <= 0) {
|
|
host.empty.textContent = t("bottomFeed.empty", "No generated assets yet.");
|
|
host.empty.style.display = "";
|
|
} else {
|
|
host.empty.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function _assetKey(asset: any) {
|
|
const id = String(asset?.id || "").trim();
|
|
if (id) return `id:${id}`;
|
|
const filename = String(asset?.filename || "").trim();
|
|
const subfolder = String(asset?.subfolder || "").trim();
|
|
const type = String(asset?.source || asset?.type || "").trim();
|
|
return `file:${type}:${subfolder}:${filename}`;
|
|
}
|
|
|
|
function _detectKindFromExt(filename: any) {
|
|
const ext = String(filename || "")
|
|
.split(".")
|
|
.pop()
|
|
?.toUpperCase() ?? "";
|
|
const videos = new Set(["MP4", "WEBM", "MOV", "AVI", "MKV"]);
|
|
const audio = new Set(["MP3", "WAV", "OGG", "FLAC", "AAC", "M4A"]);
|
|
const model3d = new Set(["OBJ", "FBX", "GLB", "GLTF", "STL", "PLY", "SPLAT", "KSPLAT", "SPZ"]);
|
|
if (videos.has(ext)) return "video";
|
|
if (audio.has(ext)) return "audio";
|
|
if (model3d.has(ext)) return "model3d";
|
|
return "image";
|
|
}
|
|
|
|
function _normalizeAsset(asset: any) {
|
|
if (!asset || typeof asset !== "object") return null;
|
|
const normalized = { ...asset };
|
|
if (!normalized.type && normalized.source) normalized.type = normalized.source;
|
|
if (!normalized.source && normalized.type) normalized.source = normalized.type;
|
|
if (!normalized.kind) {
|
|
normalized.kind = _detectKindFromExt(normalized.ext || normalized.filename || "");
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function _rememberPendingAsset(asset: any) {
|
|
const normalized = _normalizeAsset(asset);
|
|
if (!normalized) return null;
|
|
const key = _assetKey(normalized);
|
|
if (!key) return null;
|
|
const identity = getFeedAssetIdentityKey(normalized);
|
|
let identityExisting: any = null;
|
|
if (identity) {
|
|
for (const [pendingKey, pending] of FEED_STATE.pendingAssets.entries()) {
|
|
if (pendingKey !== key && getFeedAssetIdentityKey(pending) === identity) {
|
|
identityExisting = pending;
|
|
FEED_STATE.pendingAssets.delete(pendingKey);
|
|
}
|
|
}
|
|
}
|
|
const existing = FEED_STATE.pendingAssets.get(key) || identityExisting || null;
|
|
const merged = existing ? { ...(existing as Record<string, any>), ...normalized } : normalized;
|
|
if (!_isLivePlaceholderAsset(normalized)) _clearLivePlaceholderState(merged);
|
|
FEED_STATE.pendingAssets.set(key, merged);
|
|
if (FEED_STATE.pendingAssets.size > 64) {
|
|
const oldest = FEED_STATE.pendingAssets.keys().next().value;
|
|
if (oldest) FEED_STATE.pendingAssets.delete(oldest);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function _getPendingAssets() {
|
|
return Array.from(FEED_STATE.pendingAssets.values());
|
|
}
|
|
|
|
async function _applyPendingAssets(host: any) {
|
|
if (!host?.grid) return;
|
|
for (const asset of _getPendingAssets()) {
|
|
_storeHostAsset(host, asset);
|
|
}
|
|
await _renderHostAssets(host);
|
|
}
|
|
|
|
async function _loadHistory(host: any) {
|
|
if (!host?.grid) return;
|
|
host.empty.style.display = "none";
|
|
host.empty.textContent = t("bottomFeed.loading", "Loading recent assets...");
|
|
try {
|
|
host.assetsByKey = new Map();
|
|
const assets: any[] = [];
|
|
let offset = 0;
|
|
while (offset < FEED_FETCH_LIMIT) {
|
|
const url = buildListURL({
|
|
q: "*",
|
|
limit: Math.min(FEED_PAGE_SIZE, FEED_FETCH_LIMIT - offset),
|
|
offset,
|
|
scope: "output",
|
|
sort: "mtime_desc",
|
|
includeTotal: offset === 0,
|
|
});
|
|
const page = await get(url, { timeoutMs: 30_000 });
|
|
if (!page?.ok) {
|
|
throw new Error(String(page?.error || "Failed to load feed assets"));
|
|
}
|
|
const rows = Array.isArray(page?.data?.assets) ? page.data.assets : [];
|
|
if (!rows.length) break;
|
|
for (const row of rows) {
|
|
const normalized = _storeHostAsset(host, row);
|
|
if (normalized) assets.push(normalized);
|
|
}
|
|
if (rows.length < FEED_PAGE_SIZE) break;
|
|
offset += rows.length;
|
|
}
|
|
|
|
// Merge pending assets into the host store before the single render pass.
|
|
// This avoids the double-reset that happened when _applyPendingAssets called
|
|
// _renderHostAssets (reset:true) after loadAssetsFromList had already done so,
|
|
// which discarded the _enhanceGroupedCards work done between the two calls.
|
|
for (const asset of _getPendingAssets()) {
|
|
_storeHostAsset(host, asset);
|
|
}
|
|
// Pending assets are now absorbed into host.assetsByKey - clear the global buffer
|
|
// so future hosts don't replay the same entries (would cause duplicates).
|
|
FEED_STATE.pendingAssets.clear();
|
|
|
|
const allAssets = _getHostAssets(host);
|
|
const grouped = _groupFeedAssets(allAssets);
|
|
const res = await loadAssetsFromList(host.grid, grouped, {
|
|
title: t("bottomFeed.title", "Generated Feed"),
|
|
reset: true,
|
|
});
|
|
_enhanceGroupedCards(host, grouped);
|
|
|
|
const count = Number((res as any)?.count || 0);
|
|
if (count <= 0) {
|
|
host.empty.textContent = t("bottomFeed.empty", "No generated assets yet.");
|
|
host.empty.style.display = "";
|
|
} else {
|
|
host.empty.style.display = "none";
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
host.empty.textContent = t("bottomFeed.loadFailed", "Failed to load generated assets.");
|
|
host.empty.style.display = "";
|
|
} finally {
|
|
host.loaded = true;
|
|
}
|
|
}
|
|
|
|
async function _refreshAllHosts() {
|
|
for (const host of Array.from(FEED_STATE.hosts) as Record<string, any>[]) {
|
|
try {
|
|
await _loadHistory(host);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function refreshGeneratedFeedHosts(): void {
|
|
void _refreshAllHosts();
|
|
}
|
|
|
|
function _buildEmpty(host: any) {
|
|
const empty = document.createElement("div");
|
|
empty.textContent = t("bottomFeed.loading", "Loading recent assets...");
|
|
empty.style.cssText = "font-size:11px; opacity:0.65; padding:4px 2px 8px 2px;";
|
|
host.root.appendChild(empty);
|
|
host.empty = empty;
|
|
}
|
|
|
|
function _prepareBottomPanelContainer(container: any) {
|
|
if (!container) return;
|
|
const targets = [container, container.parentElement, container.parentElement?.parentElement];
|
|
for (const el of targets) {
|
|
if (!el || !(el instanceof HTMLElement)) continue;
|
|
try {
|
|
el.style.minHeight = "0";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
try {
|
|
container.style.height = "100%";
|
|
container.style.minHeight = "0";
|
|
container.style.overflow = "hidden";
|
|
container.style.display = "flex";
|
|
container.style.flexDirection = "column";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function _applyFeedSettingsClasses(grid: any) {
|
|
if (!grid) return;
|
|
const toggle = (cls: any, enabled: any) => {
|
|
if (enabled) grid.classList.add(cls);
|
|
else grid.classList.remove(cls);
|
|
};
|
|
toggle("mjr-feed-show-info", APP_CONFIG.FEED_SHOW_INFO);
|
|
toggle("mjr-feed-show-filename", APP_CONFIG.FEED_SHOW_FILENAME);
|
|
toggle("mjr-feed-show-dimensions", APP_CONFIG.FEED_SHOW_DIMENSIONS);
|
|
toggle("mjr-feed-show-date", APP_CONFIG.FEED_SHOW_DATE);
|
|
toggle("mjr-feed-show-gentime", APP_CONFIG.FEED_SHOW_GENTIME);
|
|
toggle("mjr-show-hover-info", APP_CONFIG.GRID_SHOW_HOVER_INFO);
|
|
toggle("mjr-feed-show-dot", APP_CONFIG.FEED_SHOW_WORKFLOW_DOT);
|
|
toggle("mjr-feed-show-badges-ext", APP_CONFIG.FEED_SHOW_BADGES_EXTENSION);
|
|
toggle("mjr-feed-show-badges-rating", APP_CONFIG.FEED_SHOW_BADGES_RATING);
|
|
toggle("mjr-feed-show-badges-tags", APP_CONFIG.FEED_SHOW_BADGES_TAGS);
|
|
}
|
|
|
|
function _buildGrid(host: any, externalGridWrapper = null) {
|
|
const wrapper =
|
|
externalGridWrapper ||
|
|
(() => {
|
|
const el = document.createElement("div");
|
|
return el;
|
|
})();
|
|
wrapper.classList.add("mjr-am-grid-scroll");
|
|
wrapper.style.position = "relative";
|
|
wrapper.style.flex = "1 1 auto";
|
|
wrapper.style.minHeight = "0";
|
|
wrapper.style.height = "100%";
|
|
wrapper.style.overflow = "auto";
|
|
wrapper.style.scrollbarGutter = "stable";
|
|
wrapper.style.overscrollBehavior = "contain";
|
|
(wrapper.style as any).webkitOverflowScrolling = "touch";
|
|
const markActive = () => {
|
|
try {
|
|
(window as any).__MJR_LAST_SELECTION_GRID__ = host.grid;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
const openMfv = () => {
|
|
markActive();
|
|
try {
|
|
window.dispatchEvent(new Event(EVENTS.MFV_OPEN));
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
const mountTarget = document.createElement("div");
|
|
wrapper.appendChild(mountTarget);
|
|
host.root.appendChild(wrapper);
|
|
|
|
const { app } = createMjrApp(VirtualAssetGridHost, {
|
|
scrollElement: wrapper,
|
|
virtualize: false,
|
|
applyDefaultSettingsClasses: false,
|
|
// MFV listens on window selection events; feed selection changes must be forwarded too.
|
|
emitWindowSelectionEvents: true,
|
|
onCardRendered: (card: any, asset: any) => {
|
|
try {
|
|
_decorateFeedCard(host, card, asset);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onCardDblclick: () => {
|
|
openMfv();
|
|
},
|
|
});
|
|
const vm = app.mount(mountTarget);
|
|
const grid = (vm as any)?.gridContainer || mountTarget.querySelector?.(".mjr-grid") || null;
|
|
host.gridApp = app;
|
|
host.gridVm = vm;
|
|
host.grid = grid;
|
|
if (!grid) return;
|
|
|
|
grid.style.minHeight = "100%";
|
|
grid.style.height = "auto";
|
|
grid.dataset.mjrScope = "output";
|
|
grid.dataset.mjrQuery = "*";
|
|
grid.dataset.mjrSort = "mtime_desc";
|
|
grid.dataset.mjrBottomFeed = "true";
|
|
const feedMinSize = Number(APP_CONFIG.FEED_GRID_MIN_SIZE || APP_CONFIG.GRID_MIN_SIZE || 120);
|
|
grid.dataset.mjrFeedMinItemWidth = String(feedMinSize);
|
|
grid.style.setProperty("--mjr-grid-min-size", `${feedMinSize}px`);
|
|
grid.addEventListener("keydown", (event: any) => {
|
|
const lower = event?.key?.toLowerCase?.() || "";
|
|
if (event?.ctrlKey || event?.metaKey || event?.altKey) return;
|
|
if (lower === "v") {
|
|
event.preventDefault?.();
|
|
event.stopPropagation?.();
|
|
openMfv();
|
|
return;
|
|
}
|
|
if (lower === "c") {
|
|
event.preventDefault?.();
|
|
event.stopPropagation?.();
|
|
markActive();
|
|
try {
|
|
void loadFloatingViewerManagerModule()
|
|
.then(async ({ floatingViewerManager }) => {
|
|
await floatingViewerManager.open();
|
|
floatingViewerManager.toggleCompareAB();
|
|
})
|
|
.catch((err) => console.debug?.(err));
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
});
|
|
grid.addEventListener("pointerdown", markActive, true);
|
|
grid.addEventListener("focusin", markActive, true);
|
|
host._disposeFeedFocus = () => {
|
|
try {
|
|
grid.removeEventListener("pointerdown", markActive, true);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
grid.removeEventListener("focusin", markActive, true);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
}
|
|
|
|
function _getFeedSelectedAssets(host: any) {
|
|
const grid = host?.grid;
|
|
if (!grid) return [];
|
|
const selectedIds = getSelectedIdSet(grid);
|
|
if (!selectedIds.size) return [];
|
|
const assets = _getHostAssets(host);
|
|
return assets.filter((asset) => selectedIds.has(String(asset?.id || "")));
|
|
}
|
|
|
|
function _getFeedActiveAsset(host: any) {
|
|
const grid = host?.grid;
|
|
if (!grid) return null;
|
|
const activeId = String(grid.dataset?.mjrSelectedAssetId || "").trim();
|
|
if (!activeId) return _getFeedSelectedAssets(host)[0] || null;
|
|
return _getHostAssets(host).find((asset) => String(asset?.id || "") === activeId) || null;
|
|
}
|
|
|
|
function _safeCssEscapeAttr(value: any) {
|
|
const str = String(value ?? "");
|
|
try {
|
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
return CSS.escape(str);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return str.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
|
}
|
|
|
|
function _bindFeedKeyboard(host: any) {
|
|
const grid = host?.grid;
|
|
if (!grid) return;
|
|
|
|
const onAssetChanged = () => {
|
|
try {
|
|
_scheduleHostRender(host);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
host.keyboard = installGridKeyboard({
|
|
gridContainer: grid,
|
|
getState: () => ({ assets: _groupFeedAssets(_getHostAssets(host)) }),
|
|
getSelectedAssets: () => _getFeedSelectedAssets(host),
|
|
getActiveAsset: () => _getFeedActiveAsset(host),
|
|
onOpenTagsEditor: (asset) => {
|
|
const activeCard =
|
|
grid.querySelector?.(
|
|
`.mjr-asset-card[data-mjr-asset-id="${_safeCssEscapeAttr(asset?.id || "")}"]`,
|
|
) || null;
|
|
let rect: any = null;
|
|
try {
|
|
rect = activeCard?.getBoundingClientRect?.() || grid.getBoundingClientRect();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const x = Math.round((rect?.left || 0) + Math.min((rect?.width || 0) * 0.5, 160));
|
|
const y = Math.round((rect?.top || 0) + Math.min((rect?.height || 0) * 0.5, 120));
|
|
showTagsPopover(x, y, asset, onAssetChanged);
|
|
},
|
|
onAssetChanged,
|
|
});
|
|
host.keyboard.bind();
|
|
|
|
host.ratingHotkeys = createRatingHotkeysController({
|
|
gridContainer: grid,
|
|
createRatingBadge,
|
|
});
|
|
host.ratingHotkeys.bind();
|
|
}
|
|
|
|
const FEED_SIZE_MIN = 60;
|
|
const FEED_SIZE_MAX = 600;
|
|
const FEED_SIZE_STEP = 10;
|
|
|
|
function _applyFeedMinSize(host: any, rawSize: any) {
|
|
const clamped = Math.max(
|
|
FEED_SIZE_MIN,
|
|
Math.min(FEED_SIZE_MAX, Math.round(Number(rawSize) / FEED_SIZE_STEP) * FEED_SIZE_STEP),
|
|
);
|
|
// Apply CSS variable immediately on the grid element for live feedback.
|
|
if (host?.grid) {
|
|
try {
|
|
host.grid.style.setProperty("--mjr-grid-min-size", `${clamped}px`);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
// Persist to settings and update APP_CONFIG.
|
|
APP_CONFIG.FEED_GRID_MIN_SIZE = clamped;
|
|
try {
|
|
const settings = loadMajoorSettings();
|
|
settings.feed.minSize = clamped;
|
|
saveMajoorSettings(settings);
|
|
applySettingsToConfig(settings);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Notify Vue grid to recompute columnCount.
|
|
try {
|
|
window.dispatchEvent(
|
|
new CustomEvent("mjr-settings-changed", { detail: { key: "feed.minSize" } }),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Update toolbar display.
|
|
if (host?._sizeToolbar) {
|
|
try {
|
|
const slider = host._sizeToolbar.querySelector(".mjr-feed-size-slider");
|
|
const display = host._sizeToolbar.querySelector(".mjr-feed-size-display");
|
|
if (slider) slider.value = String(clamped);
|
|
if (display) display.textContent = `${clamped}px`;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
return clamped;
|
|
}
|
|
|
|
function _buildSizeControl(host: any) {
|
|
const currentSize = Math.max(
|
|
FEED_SIZE_MIN,
|
|
Number(APP_CONFIG.FEED_GRID_MIN_SIZE || 120) || 120,
|
|
);
|
|
|
|
const toolbar = document.createElement("div");
|
|
toolbar.className = "mjr-feed-toolbar";
|
|
toolbar.style.cssText =
|
|
"display:flex; align-items:center; gap:5px; padding:0 2px 5px 2px; flex-shrink:0; user-select:none;";
|
|
|
|
const label = document.createElement("span");
|
|
label.textContent = t("bottomFeed.cardSize", "Cards:");
|
|
label.style.cssText = "font-size:11px; opacity:0.65; white-space:nowrap; flex-shrink:0;";
|
|
toolbar.appendChild(label);
|
|
|
|
const btnMinus = document.createElement("button");
|
|
btnMinus.type = "button";
|
|
btnMinus.className = "mjr-feed-size-btn";
|
|
btnMinus.textContent = "-";
|
|
btnMinus.title = t("bottomFeed.cardSizeDecrease", "Decrease card size");
|
|
btnMinus.style.cssText =
|
|
"width:20px; height:20px; padding:0; border:1px solid var(--mjr-border,#555); border-radius:4px; background:var(--mjr-surface,#2a2a2a); color:inherit; cursor:pointer; font-size:14px; line-height:1; flex-shrink:0; display:flex; align-items:center; justify-content:center;";
|
|
toolbar.appendChild(btnMinus);
|
|
|
|
const slider = document.createElement("input");
|
|
slider.type = "range";
|
|
slider.className = "mjr-feed-size-slider";
|
|
slider.min = String(FEED_SIZE_MIN);
|
|
slider.max = String(FEED_SIZE_MAX);
|
|
slider.step = String(FEED_SIZE_STEP);
|
|
slider.value = String(currentSize);
|
|
slider.title = t("bottomFeed.cardSizeSlider", "Card size");
|
|
slider.style.cssText =
|
|
"flex:1 1 auto; min-width:40px; max-width:180px; height:4px; cursor:pointer; accent-color:var(--mjr-accent,#64b5f6);";
|
|
toolbar.appendChild(slider);
|
|
|
|
const btnPlus = document.createElement("button");
|
|
btnPlus.type = "button";
|
|
btnPlus.className = "mjr-feed-size-btn";
|
|
btnPlus.textContent = "+";
|
|
btnPlus.title = t("bottomFeed.cardSizeIncrease", "Increase card size");
|
|
btnPlus.style.cssText = btnMinus.style.cssText;
|
|
toolbar.appendChild(btnPlus);
|
|
|
|
const display = document.createElement("span");
|
|
display.className = "mjr-feed-size-display";
|
|
display.textContent = `${currentSize}px`;
|
|
display.style.cssText =
|
|
"font-size:11px; opacity:0.65; white-space:nowrap; min-width:36px; text-align:right; flex-shrink:0; font-variant-numeric:tabular-nums;";
|
|
toolbar.appendChild(display);
|
|
|
|
btnMinus.addEventListener("click", () => {
|
|
_applyFeedMinSize(host, Number(slider.value) - FEED_SIZE_STEP);
|
|
});
|
|
btnPlus.addEventListener("click", () => {
|
|
_applyFeedMinSize(host, Number(slider.value) + FEED_SIZE_STEP);
|
|
});
|
|
slider.addEventListener("input", () => {
|
|
_applyFeedMinSize(host, Number(slider.value));
|
|
});
|
|
|
|
host.root.appendChild(toolbar);
|
|
host._sizeToolbar = toolbar;
|
|
}
|
|
|
|
function _makeHost(container: any, external = {}) {
|
|
const root = document.createElement("div");
|
|
root.className = "mjr-assets-manager mjr-bottom-feed";
|
|
root.style.cssText =
|
|
"padding:6px; height:100%; min-height:0; box-sizing:border-box; display:flex; flex-direction:column; overflow:hidden;";
|
|
container.replaceChildren(root);
|
|
|
|
const host: Record<string, any> = {
|
|
container,
|
|
root,
|
|
grid: null,
|
|
empty: null,
|
|
loaded: false,
|
|
assetsByKey: new Map(),
|
|
popoverManager: null,
|
|
};
|
|
_buildSizeControl(host);
|
|
_buildEmpty(host);
|
|
_buildGrid(host, (external as any).gridWrapper ?? null);
|
|
_bindFeedSelection(host);
|
|
host.popoverManager = createPopoverManager(root);
|
|
|
|
// Bind the same context menu as the main grid.
|
|
// The feed is output-only so scope is always "output" - no browser/custom-root
|
|
// items will appear. Delete, rename, rate, tag, find-similar all work normally.
|
|
host._disposeContextMenu = bindGridContextMenu({
|
|
gridContainer: host.grid,
|
|
getState: () => ({ scope: "output" }),
|
|
});
|
|
_bindFeedKeyboard(host);
|
|
|
|
// Apply feed display settings and listen for changes
|
|
_applyFeedSettingsClasses(host.grid);
|
|
const onSettingsChanged = () => {
|
|
try {
|
|
_applyFeedSettingsClasses(host.grid);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
window.addEventListener("mjr-settings-changed", onSettingsChanged);
|
|
host._disposeFeedSettings = () => {
|
|
try {
|
|
window.removeEventListener("mjr-settings-changed", onSettingsChanged);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
return host;
|
|
}
|
|
|
|
export function pushGeneratedAsset(asset: Record<string, any> | null): void {
|
|
const normalized = _rememberPendingAsset(asset);
|
|
if (!normalized) return;
|
|
for (const host of Array.from(FEED_STATE.hosts) as Record<string, any>[]) {
|
|
try {
|
|
if (!host?.grid) continue;
|
|
_storeHostAsset(host, normalized);
|
|
if (!host.loaded) continue;
|
|
host.empty.style.display = "none";
|
|
_scheduleHostRender(host);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createGeneratedFeedHost(container: HTMLElement, external: Record<string, any> = {}): Record<string, any> {
|
|
_prepareBottomPanelContainer(container);
|
|
const host = _makeHost(container, external);
|
|
try {
|
|
(container as any)._mjrGeneratedFeedHost = host;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
FEED_STATE.hosts.add(host);
|
|
void _loadHistory(host);
|
|
return host;
|
|
}
|
|
|
|
export function disposeGeneratedFeedHost(host: Record<string, any> | null): void {
|
|
const resolvedHost = host?._mjrGeneratedFeedHost || host || null;
|
|
if (!resolvedHost) return;
|
|
const container = resolvedHost.container || null;
|
|
if (container) {
|
|
try {
|
|
delete (container as any)._mjrGeneratedFeedHost;
|
|
} catch (e) {
|
|
try {
|
|
(container as any)._mjrGeneratedFeedHost = null;
|
|
} catch (err) {
|
|
console.debug?.(err);
|
|
}
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
FEED_STATE.hosts.delete(resolvedHost);
|
|
try {
|
|
resolvedHost._disposeSelection?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost._disposeFeedFocus?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost._disposeFeedSettings?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost._disposeContextMenu?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost.keyboard?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost.ratingHotkeys?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost.popoverManager?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost.gridApp?.unmount?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (resolvedHost._renderTimer) {
|
|
clearTimeout(resolvedHost._renderTimer);
|
|
resolvedHost._renderTimer = null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
disposeGrid(resolvedHost.grid);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
resolvedHost.container?.replaceChildren?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
export function getGeneratedFeedBottomPanelTab(): Record<string, any> {
|
|
return {
|
|
id: FEED_TAB_ID,
|
|
title: t("bottomFeed.title", "Generated Feed"),
|
|
icon: "pi pi-images",
|
|
type: "custom",
|
|
render: (el: any) => createGeneratedFeedHost(el),
|
|
destroy: (hostOrEl: any) => disposeGeneratedFeedHost(hostOrEl),
|
|
};
|
|
}
|