Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/panel/controllers/sidebarController.js
T
MajoorWaldi 2fa41a0e76 feat: add useGridState and useVirtualGrid composables for asset management
- Implemented useGridState to manage grid state, including asset selection and loading states.
- Created useVirtualGrid to centralize virtualization helpers for improved performance.
- Added createVueApp for isolated Vue 3 + Pinia application instances.
- Introduced Vite configuration for building the ComfyUI-Majoor-AssetsManager with external dependencies.
2026-04-03 11:04:55 +02:00

1065 lines
35 KiB
JavaScript

import { applyAssetStatusDotState, createWorkflowDot } from "../../../components/Badges.js";
import { post, getAssetMetadata, vectorIndexAsset } from "../../../api/client.js";
import { ENDPOINTS } from "../../../api/endpoints.js";
import { pickRootId } from "../../../utils/ids.js";
import { safeClosest } from "../../../utils/dom.js";
import { comfyToast } from "../../../app/toast.js";
import { t } from "../../../app/i18n.js";
import { createPanelStateBridge } from "../../../stores/panelStateBridge.js";
const RESCAN_FLAG = "_mjrRescanning";
const RESCAN_TTL_MS = 1500;
const _isPositiveIntId = (value) => /^\d+$/.test(String(value ?? "").trim());
async function rescanSingleAsset({ card, asset, sidebar, onAssetUpdated }) {
if (!card || !asset) return;
if (globalThis._mjrMaintenanceActive) return;
try {
const now = Date.now();
const last = Number(card[RESCAN_FLAG] || 0) || 0;
if (now - last < RESCAN_TTL_MS) return;
card[RESCAN_FLAG] = now;
} catch (e) {
console.debug?.(e);
}
const dot = card.querySelector(".mjr-workflow-dot");
try {
if (dot) {
applyAssetStatusDotState(dot, "pending", "Pending: metadata refresh in progress", {
asset,
});
dot.classList.add("mjr-pulse-animation");
dot.style.cursor = "progress";
dot.title = t("tooltip.pendingRefresh", "Pending: metadata refresh in progress");
}
} catch (e) {
console.debug?.(e);
}
try {
comfyToast(
t("toast.rescanUpdatingAiIndex", "Rescanning file + updating AI index..."),
"info",
2200,
);
} catch (e) {
console.debug?.(e);
}
const fileEntry = {
filename: asset.filename,
subfolder: asset.subfolder || "",
type: String(asset.type || "output").toLowerCase(),
root_id: pickRootId(asset) || undefined,
};
let updated = null;
let indexOk = false;
let vectorAttempted = false;
let vectorIndexOk = false;
try {
const indexRes = await post(ENDPOINTS.INDEX_FILES, {
files: [fileEntry],
incremental: false,
});
indexOk = !!indexRes?.ok;
if (!indexOk && dot) {
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed", { asset });
dot.classList.remove("mjr-pulse-animation");
dot.style.cursor = "";
}
// Keep AI search index synced with manual per-asset rescan from the card dot.
// Best-effort: do not fail metadata refresh if vector indexing fails or is unavailable.
if (_isPositiveIntId(asset?.id)) {
vectorAttempted = true;
try {
const vectorRes = await vectorIndexAsset(asset.id);
vectorIndexOk = !!vectorRes?.ok;
} catch (e) {
console.debug?.(e);
}
}
if (indexOk && asset.id != null) {
const fresh = await getAssetMetadata(asset.id);
if (fresh?.ok && fresh.data) {
updated = { ...asset, ...fresh.data };
}
}
} catch {
try {
if (dot) {
applyAssetStatusDotState(dot, "error", "Error: metadata refresh failed", { asset });
dot.classList.remove("mjr-pulse-animation");
dot.style.cursor = "";
}
} catch (e) {
console.debug?.(e);
}
}
try {
if (updated) {
card._mjrAsset = updated;
if (typeof onAssetUpdated === "function") onAssetUpdated(updated);
try {
if (sidebar?.classList?.contains?.("is-open")) {
const curId =
sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null;
if (curId != null && String(curId) === String(updated.id)) {
sidebar._mjrNeedsRefresh = true;
}
}
} catch (e) {
console.debug?.(e);
}
}
} finally {
try {
if (dot && dot.isConnected) {
const restored = createWorkflowDot(updated || asset);
if (restored) dot.replaceWith(restored);
}
} catch {
try {
if (dot) dot.style.cursor = "";
} catch (e) {
console.debug?.(e);
}
}
}
try {
if (indexOk && vectorIndexOk) {
comfyToast(
t(
"toast.metadataVectorUpdated",
"Metadata + AI vector index updated for this asset.",
),
"success",
2200,
);
} else if (indexOk && vectorAttempted && !vectorIndexOk) {
comfyToast(
t(
"toast.metadataUpdatedVectorFailed",
"Metadata updated. AI vector index could not be updated.",
),
"warning",
2600,
);
}
} catch (e) {
console.debug?.(e);
}
return updated;
}
function setSelectedCard(gridContainer, selectedCard) {
if (!gridContainer) return;
try {
const prev = gridContainer.querySelector(".mjr-asset-card.is-selected");
if (prev && prev !== selectedCard) {
prev.classList.remove("is-selected");
prev.setAttribute?.("aria-selected", "false");
}
} catch (e) {
console.debug?.(e);
}
if (selectedCard) {
selectedCard.classList.add("is-selected");
selectedCard.setAttribute?.("aria-selected", "true");
try {
const id = selectedCard.dataset?.mjrAssetId;
if (id) gridContainer.dataset.mjrSelectedAssetId = String(id);
} catch (e) {
console.debug?.(e);
}
try {
selectedCard.focus?.({ preventScroll: true });
} catch {
try {
selectedCard.focus?.();
} catch (e) {
console.debug?.(e);
}
}
try {
selectedCard.scrollIntoView?.({ block: "nearest", inline: "nearest" });
} catch (e) {
console.debug?.(e);
}
}
}
const escapeAttributeValue = (value) => {
if (value == null) return "";
try {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(String(value));
}
} catch (e) {
console.debug?.(e);
}
return String(value).replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
};
function queryCardById(gridContainer, assetId) {
if (!gridContainer || !assetId) return null;
try {
return gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${escapeAttributeValue(assetId)}"]`,
);
} catch (e) {
console.debug?.(e);
}
try {
return gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${escapeAttributeValue(assetId)}"]`,
);
} catch (e) {
console.debug?.(e);
}
return null;
}
function getActiveCardElement(gridContainer) {
if (!gridContainer) return null;
const activeId = gridContainer.dataset?.mjrSelectedAssetId;
if (activeId) {
const card = queryCardById(gridContainer, activeId);
if (card) return card;
}
const selectedIds = gridContainer.dataset?.mjrSelectedAssetIds;
if (selectedIds) {
try {
const parsed = JSON.parse(selectedIds);
if (Array.isArray(parsed) && parsed.length) {
const card = queryCardById(gridContainer, parsed[0]);
if (card) return card;
}
} catch (e) {
console.debug?.(e);
}
}
return gridContainer.querySelector(".mjr-asset-card.is-selected");
}
function focusCardElement(card, { preventScroll = true } = {}) {
if (!card) return;
try {
card.focus?.({ preventScroll });
} catch {
try {
card.focus?.();
} catch (e) {
console.debug?.(e);
}
}
}
function focusActiveCard(gridContainer, { force = false } = {}) {
if (!gridContainer) return;
if (!force && !gridContainer._mjrFocusWithin) return;
const card = getActiveCardElement(gridContainer);
if (!card) return;
focusCardElement(card);
gridContainer._mjrFocusWithin = true;
}
function ensureSelectionVisible(gridContainer) {
if (!gridContainer) return;
try {
const selected = getActiveCardElement(gridContainer);
if (!selected) return;
const root = gridContainer.parentElement;
if (
root &&
typeof root.getBoundingClientRect === "function" &&
typeof selected.getBoundingClientRect === "function"
) {
const rootRect = root.getBoundingClientRect();
const selRect = selected.getBoundingClientRect();
const fullyVisible = selRect.top >= rootRect.top && selRect.bottom <= rootRect.bottom;
if (fullyVisible) return;
}
selected.scrollIntoView?.({ block: "nearest", inline: "nearest" });
} catch (e) {
console.debug?.(e);
}
}
function scheduleEnsureSelectionVisible(gridContainer) {
if (gridContainer) {
try {
gridContainer._mjrAutoRevealSelectionUntil = Date.now() + 900;
} catch (e) {
console.debug?.(e);
}
}
const runOnce = () => {
try {
ensureSelectionVisible(gridContainer);
} catch (e) {
console.debug?.(e);
}
try {
focusActiveCard(gridContainer);
} catch (e) {
console.debug?.(e);
}
};
try {
requestAnimationFrame(() => {
runOnce();
// Second pass after layout settles (sidebar open/close).
setTimeout(() => {
runOnce();
}, 50);
});
} catch {
runOnce();
}
}
function setCardSelected(card, selected) {
if (!card) return;
if (selected) {
card.classList.add("is-selected");
card.setAttribute?.("aria-selected", "true");
} else {
card.classList.remove("is-selected");
card.setAttribute?.("aria-selected", "false");
}
}
function getAllCards(gridContainer) {
try {
if (typeof gridContainer?._mjrGetRenderedCards === "function") {
const cards = gridContainer._mjrGetRenderedCards();
if (Array.isArray(cards) && cards.length) return cards;
}
} catch {
return [];
}
try {
return Array.from(gridContainer.querySelectorAll(".mjr-asset-card"));
} catch {
return [];
}
}
function updateSelectedIdsDataset(gridContainer) {
if (!gridContainer) return;
try {
const ids = getAllCards(gridContainer)
.filter((c) => c?.classList?.contains?.("is-selected"))
.map((c) => c?.dataset?.mjrAssetId)
.filter(Boolean);
if (ids.length) {
gridContainer.dataset.mjrSelectedAssetIds = JSON.stringify(ids);
if (!gridContainer.dataset.mjrSelectedAssetId && ids[0]) {
gridContainer.dataset.mjrSelectedAssetId = String(ids[0]);
}
} else {
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e) {
console.debug?.(e);
}
}
function applySelection(gridContainer, ids, activeId = "") {
if (!gridContainer) return [];
const list = Array.from(ids || [])
.map(String)
.filter(Boolean);
try {
if (typeof gridContainer?._mjrSetSelection === "function") {
gridContainer._mjrSetSelection(list, activeId || list[0] || "");
return list;
}
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.dataset.mjrSelectedAssetIds = list.length ? JSON.stringify(list) : "";
if (list.length)
gridContainer.dataset.mjrSelectedAssetId = String(activeId || list[0] || "");
else {
delete gridContainer.dataset.mjrSelectedAssetIds;
delete gridContainer.dataset.mjrSelectedAssetId;
}
} catch (e) {
console.debug?.(e);
}
try {
for (const card of getAllCards(gridContainer)) {
const id = card?.dataset?.mjrAssetId;
const selected = id && list.includes(String(id));
setCardSelected(card, !!selected);
}
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.dispatchEvent?.(
new CustomEvent("mjr:selection-changed", {
detail: { selectedIds: list, activeId: activeId || list[0] || "" },
}),
);
} catch (e) {
console.debug?.(e);
}
return list;
}
function syncSelectionState({ gridContainer, state, activeId, readState = null, writeState = null } = {}) {
if ((!state && !writeState) || !gridContainer) return;
try {
let selected = [];
if (gridContainer.dataset?.mjrSelectedAssetIds) {
try {
const parsed = JSON.parse(gridContainer.dataset.mjrSelectedAssetIds);
if (Array.isArray(parsed)) selected = parsed.map(String).filter(Boolean);
} catch (e) {
console.debug?.(e);
}
} else if (gridContainer.dataset?.mjrSelectedAssetId) {
selected = [String(gridContainer.dataset.mjrSelectedAssetId)].filter(Boolean);
} else {
selected = getAllCards(gridContainer)
.filter((c) => c?.classList?.contains?.("is-selected"))
.map((c) => c?.dataset?.mjrAssetId)
.filter(Boolean)
.map(String);
}
if (typeof writeState === "function") {
writeState("selectedAssetIds", selected);
} else {
state.selectedAssetIds = selected;
}
const prevActiveId =
typeof readState === "function"
? String(readState("activeAssetId", "") || "")
: String(state?.activeAssetId || "");
if (activeId != null) {
if (typeof writeState === "function") writeState("activeAssetId", String(activeId));
else state.activeAssetId = String(activeId);
} else if (prevActiveId && selected.includes(prevActiveId)) {
// keep existing active
} else {
if (typeof writeState === "function") writeState("activeAssetId", selected[0] || "");
else state.activeAssetId = selected[0] || "";
}
} catch (e) {
console.debug?.(e);
}
}
export function bindSidebarOpen({
gridContainer,
sidebar,
createRatingBadge,
createTagsBadge,
showAssetInSidebar,
closeSidebar,
state,
}) {
const { read, write } = createPanelStateBridge(state, [
"activeAssetId",
"selectedAssetIds",
"sidebarOpen",
]);
if (!gridContainer) return { dispose: () => {} };
if (gridContainer._mjrSidebarOpenBound) {
return { dispose: gridContainer._mjrSidebarOpenDispose || (() => {}) };
}
gridContainer._mjrSidebarOpenBound = true;
const onGridFocusIn = (event) => {
gridContainer._mjrFocusWithin = true;
const card = safeClosest(event.target, ".mjr-asset-card");
if (card) {
try {
gridContainer._mjrLastFocusedCardId = card.dataset?.mjrAssetId || "";
} catch (e) {
console.debug?.(e);
}
}
};
const onGridFocusOut = (event) => {
const related = event?.relatedTarget;
if (related && gridContainer.contains(related)) return;
gridContainer._mjrFocusWithin = false;
};
try {
gridContainer.addEventListener("focusin", onGridFocusIn);
gridContainer.addEventListener("focusout", onGridFocusOut);
} catch (e) {
console.debug?.(e);
}
// Keep selection visible when the panel/grid resizes (column wrap changes can move the selected card).
// The VirtualGrid repositions cards after a 100ms debounce, so we need multiple passes
// to ensure we scroll after the grid has finished re-laying out.
try {
if (!gridContainer._mjrSelectionResizeObserver) {
const scrollRoot = gridContainer.parentElement;
let raf = null;
let timer = null;
const shouldAutoRevealSelection = () => {
try {
return Date.now() <= Number(gridContainer?._mjrAutoRevealSelectionUntil || 0);
} catch {
return false;
}
};
const schedule = () => {
if (raf) cancelAnimationFrame(raf);
if (timer) clearTimeout(timer);
// First pass: immediate after layout
raf = requestAnimationFrame(() => {
raf = null;
if (shouldAutoRevealSelection()) {
ensureSelectionVisible(gridContainer);
}
});
// Second pass: after VirtualGrid debounce (100ms) + render
timer = setTimeout(() => {
timer = null;
if (shouldAutoRevealSelection()) {
ensureSelectionVisible(gridContainer);
}
}, 160);
};
const ro = new ResizeObserver(() => schedule());
if (scrollRoot) ro.observe(scrollRoot);
ro.observe(gridContainer);
gridContainer._mjrSelectionResizeObserver = ro;
}
} catch (e) {
console.debug?.(e);
}
const applyAssetUpdateToCard = (card, asset, updatedAsset) => {
// Keep workflow/gen flags (and dot) in sync when backend self-heals metadata.
try {
const nextHasWorkflow =
updatedAsset?.has_workflow ?? updatedAsset?.hasWorkflow ?? asset.has_workflow;
const nextHasGen =
updatedAsset?.has_generation_data ??
updatedAsset?.hasGenerationData ??
asset.has_generation_data;
if (nextHasWorkflow !== undefined) asset.has_workflow = nextHasWorkflow;
if (nextHasGen !== undefined) asset.has_generation_data = nextHasGen;
const oldDot = card.querySelector(".mjr-workflow-dot");
const newDot = createWorkflowDot(asset);
if (oldDot && newDot) oldDot.replaceWith(newDot);
} catch (e) {
console.debug?.(e);
}
if (updatedAsset?.rating !== undefined && updatedAsset.rating !== asset.rating) {
asset.rating = updatedAsset.rating;
const thumb = card.querySelector(".mjr-thumb");
const oldRatingBadge = card.querySelector(".mjr-rating-badge");
const newBadge = createRatingBadge(updatedAsset.rating || 0);
if (oldRatingBadge && newBadge) {
oldRatingBadge.replaceWith(newBadge);
} else if (oldRatingBadge && !newBadge) {
oldRatingBadge.remove();
} else if (!oldRatingBadge && newBadge && thumb) {
thumb.appendChild(newBadge);
}
}
if (
updatedAsset?.tags &&
JSON.stringify(updatedAsset.tags) !== JSON.stringify(asset.tags)
) {
asset.tags = updatedAsset.tags;
const oldTagsBadge = card.querySelector(".mjr-tags-badge");
if (oldTagsBadge) {
const newBadge = createTagsBadge(updatedAsset.tags || []);
oldTagsBadge.replaceWith(newBadge);
}
}
};
const onClick = async (e) => {
if (e.defaultPrevented) return;
const card = safeClosest(e.target, ".mjr-asset-card");
if (!card) return;
// Click on workflow dot triggers a forced re-index of this one file (no directory scan).
const dot = safeClosest(e.target, ".mjr-workflow-dot");
if (dot && card.contains(dot)) {
const asset = card._mjrAsset;
if (!asset) return;
e.preventDefault();
e.stopPropagation();
// Ensure this card is selected, but do not open the sidebar from dot clicks.
const id = card?.dataset?.mjrAssetId || "";
applySelection(gridContainer, id ? [id] : [], id);
syncSelectionState({ gridContainer, state, activeId: id, readState: read, writeState: write });
const updated = await rescanSingleAsset({
card,
asset,
sidebar,
onAssetUpdated: (u) => applyAssetUpdateToCard(card, asset, u),
});
// Refresh sidebar only if it is already open on this asset.
try {
const curId = sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null;
if (
updated &&
sidebar?.classList?.contains?.("is-open") &&
curId != null &&
String(curId) === String(updated.id)
) {
await showAssetInSidebar(sidebar, updated, (u) =>
applyAssetUpdateToCard(card, asset, u),
);
}
} catch (e) {
console.debug?.(e);
}
return;
}
const interactive = safeClosest(e.target, "a, button, input, textarea, select, label");
if (interactive && card.contains(interactive)) return;
const asset = card._mjrAsset;
if (!asset) return;
if (gridContainer?._mjrSelectionManagedByVue) {
const clickedId = String(card?.dataset?.mjrAssetId || "").trim();
syncSelectionState({
gridContainer,
state,
activeId: clickedId,
readState: read,
writeState: write,
});
try {
if (sidebar?.classList?.contains?.("is-open")) {
await showAssetInSidebar(sidebar, asset, (updatedAsset) =>
applyAssetUpdateToCard(card, asset, updatedAsset),
);
try {
write("sidebarOpen", true);
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
return;
}
e.preventDefault();
e.stopPropagation();
const isMulti = !!(e.ctrlKey || e.metaKey || e.shiftKey);
if (!isMulti) {
const alreadySelected = card.classList.contains("is-selected");
if (alreadySelected) {
applySelection(gridContainer, [], "");
syncSelectionState({
gridContainer,
state,
activeId: "",
readState: read,
writeState: write,
});
return;
}
const id = card?.dataset?.mjrAssetId;
applySelection(gridContainer, id ? [id] : [], id || "");
syncSelectionState({
gridContainer,
state,
activeId: id || "",
readState: read,
writeState: write,
});
// When the sidebar is already open, clicking a different card navigates details.
try {
if (sidebar?.classList?.contains?.("is-open")) {
showAssetInSidebar(sidebar, asset, (updatedAsset) =>
applyAssetUpdateToCard(card, asset, updatedAsset),
);
}
} catch (e) {
console.debug?.(e);
}
} else {
const cards = getAllCards(gridContainer);
const idx = cards.indexOf(card);
if (e.shiftKey) {
let allAssets = [];
try {
if (typeof gridContainer?._mjrGetAssets === "function") {
allAssets = gridContainer._mjrGetAssets() || [];
}
} catch (e) {
console.debug?.(e);
}
if (Array.isArray(allAssets) && allAssets.length) {
const id = card?.dataset?.mjrAssetId || "";
const targetIndex = allAssets.findIndex(
(a) => String(a?.id || "") === String(id),
);
const last = Number(gridContainer._mjrLastSelectedIndex);
const anchor = Number.isFinite(last) ? last : targetIndex;
if (targetIndex >= 0 && anchor >= 0) {
const start = Math.min(anchor, targetIndex);
const end = Math.max(anchor, targetIndex);
const rangeIds = allAssets
.slice(start, end + 1)
.map((a) => String(a?.id || ""))
.filter(Boolean);
applySelection(gridContainer, rangeIds, id || rangeIds[0] || "");
gridContainer._mjrLastSelectedIndex = targetIndex;
syncSelectionState({
gridContainer,
state,
activeId: id || "",
readState: read,
writeState: write,
});
return;
}
}
}
const clickedId = String(card?.dataset?.mjrAssetId || "").trim();
if (idx >= 0) {
gridContainer._mjrLastSelectedIndex = idx;
}
// Build selection from dataset first (VirtualGrid-safe), then fallback to DOM.
const selectedSet = new Set();
try {
const raw = gridContainer.dataset?.mjrSelectedAssetIds;
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
for (const id of parsed) {
const sid = String(id || "").trim();
if (sid) selectedSet.add(sid);
}
}
} else {
const single = String(gridContainer.dataset?.mjrSelectedAssetId || "").trim();
if (single) selectedSet.add(single);
}
} catch (e) {
console.debug?.(e);
}
if (!selectedSet.size) {
for (const c of cards) {
const sid = String(c?.dataset?.mjrAssetId || "").trim();
if (!sid) continue;
if (c.classList?.contains?.("is-selected")) selectedSet.add(sid);
}
}
if (clickedId) {
if (selectedSet.has(clickedId)) selectedSet.delete(clickedId);
else selectedSet.add(clickedId);
}
const nextList = Array.from(selectedSet);
const nextActiveId = selectedSet.has(clickedId) ? clickedId : nextList[0] || "";
// Use canonical selection setter so both grid + window selection events are fired.
applySelection(gridContainer, nextList, nextActiveId);
syncSelectionState({
gridContainer,
state,
activeId: nextActiveId,
readState: read,
writeState: write,
});
return;
}
// Click only selects the asset (sidebar opens via hotkey/context menu).
try {
ensureSelectionVisible(gridContainer);
card?.focus?.({ preventScroll: true });
} catch (e) {
console.debug?.(e);
}
};
gridContainer.addEventListener("click", onClick);
const openDetailsForSelection = async () => {
const activeId = String(read("activeAssetId", "") || "");
let card = null;
if (activeId) {
try {
card = gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${escapeAttributeValue(activeId)}"]`,
);
} catch {
try {
card = gridContainer.querySelector(
`.mjr-asset-card[data-mjr-asset-id="${escapeAttributeValue(activeId)}"]`,
);
} catch (e) {
console.debug?.(e);
}
}
}
if (!card) {
card = gridContainer.querySelector(".mjr-asset-card.is-selected");
}
const asset = card?._mjrAsset;
if (!asset) return;
// Ensure the card keeps selection and focus when the sidebar opens
// (needed when triggered from the global hotkey which bypasses the grid keydown handler)
setSelectedCard(gridContainer, card);
updateSelectedIdsDataset(gridContainer);
syncSelectionState({
gridContainer,
state,
activeId: card?.dataset?.mjrAssetId,
readState: read,
writeState: write,
});
try {
const isOpen = !!sidebar?.classList?.contains?.("is-open");
const curId = sidebar?._currentAsset?.id ?? sidebar?._currentFullAsset?.id ?? null;
if (
isOpen &&
curId != null &&
String(curId) === String(asset.id) &&
typeof closeSidebar === "function"
) {
closeSidebar(sidebar);
// Persist sidebar closed state
try {
write("sidebarOpen", false);
} catch (e) {
console.debug?.(e);
}
focusActiveCard(gridContainer, { force: true });
scheduleEnsureSelectionVisible(gridContainer);
return;
}
} catch (e) {
console.debug?.(e);
}
try {
await showAssetInSidebar(sidebar, asset, (updatedAsset) =>
applyAssetUpdateToCard(card, asset, updatedAsset),
);
// Persist sidebar open state
try {
write("sidebarOpen", true);
} catch (e) {
console.debug?.(e);
}
scheduleEnsureSelectionVisible(gridContainer);
card?.focus?.({ preventScroll: true });
} catch (e) {
console.debug?.(e);
}
};
const refreshActiveAsset = async () => {
try {
const isOpen = !!sidebar?.classList?.contains?.("is-open");
if (!isOpen) return false;
} catch {
return false;
}
const activeId = String(read("activeAssetId", "") || "").trim();
if (!activeId) return false;
let card = null;
try {
card = queryCardById(gridContainer, activeId);
} catch (e) {
console.debug?.(e);
}
const asset = card?._mjrAsset;
if (!asset) return false;
try {
await showAssetInSidebar(sidebar, asset, (updatedAsset) =>
applyAssetUpdateToCard(card, asset, updatedAsset),
);
try {
write("sidebarOpen", true);
} catch (e) {
console.debug?.(e);
}
return true;
} catch (e) {
console.debug?.(e);
}
return false;
};
let selectionRefreshQueued = false;
const onSelectionChanged = () => {
try {
if (!sidebar?.classList?.contains?.("is-open")) return;
} catch {
return;
}
if (selectionRefreshQueued) return;
selectionRefreshQueued = true;
queueMicrotask(async () => {
selectionRefreshQueued = false;
try {
await refreshActiveAsset();
} catch (e) {
console.debug?.(e);
}
});
};
gridContainer.addEventListener("mjr:selection-changed", onSelectionChanged);
// Keyboard accessibility: toggle details from focused card.
const onKeyDown = (e) => {
if (e.defaultPrevented) return;
const lower = e.key?.toLowerCase?.() || "";
if (lower !== "d") return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const isTypingTarget =
e.target?.isContentEditable ||
e.target?.closest?.("input, textarea, select, [contenteditable='true']");
if (isTypingTarget) return;
const card = safeClosest(e.target, ".mjr-asset-card");
if (!card) return;
const asset = card._mjrAsset;
if (!asset) return;
e.preventDefault();
e.stopPropagation();
setSelectedCard(gridContainer, card);
updateSelectedIdsDataset(gridContainer);
syncSelectionState({
gridContainer,
state,
activeId: card?.dataset?.mjrAssetId,
readState: read,
writeState: write,
});
openDetailsForSelection();
};
gridContainer.addEventListener("keydown", onKeyDown);
// Listen for sidebar close events (e.g. from the close button in sidebar header)
const onSidebarClosed = () => {
try {
write("sidebarOpen", false);
} catch (e) {
console.debug?.(e);
}
};
sidebar?.addEventListener?.("mjr:sidebar-closed", onSidebarClosed);
try {
gridContainer._mjrOpenDetails = openDetailsForSelection;
} catch (e) {
console.debug?.(e);
}
const dispose = () => {
try {
gridContainer.removeEventListener("click", onClick);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener("focusin", onGridFocusIn);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener("focusout", onGridFocusOut);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener("keydown", onKeyDown);
} catch (e) {
console.debug?.(e);
}
try {
gridContainer.removeEventListener("mjr:selection-changed", onSelectionChanged);
} catch (e) {
console.debug?.(e);
}
try {
sidebar?.removeEventListener?.("mjr:sidebar-closed", onSidebarClosed);
} catch (e) {
console.debug?.(e);
}
try {
if (gridContainer._mjrOpenDetails === openDetailsForSelection) {
delete gridContainer._mjrOpenDetails;
}
} catch (e) {
console.debug?.(e);
}
try {
gridContainer._mjrSelectionResizeObserver?.disconnect?.();
} catch (e) {
console.debug?.(e);
}
try {
delete gridContainer._mjrSelectionResizeObserver;
} catch (e) {
console.debug?.(e);
}
try {
delete gridContainer._mjrSidebarOpenBound;
} catch (e) {
console.debug?.(e);
}
try {
delete gridContainer._mjrSidebarOpenDispose;
} catch (e) {
console.debug?.(e);
}
};
gridContainer._mjrSidebarOpenDispose = dispose;
return { dispose, toggleDetails: openDetailsForSelection, refreshActiveAsset };
}